tinyhumansai/openhuman · error
[tunnel] no session cipher — handshake incomplete
Error message
[tunnel] no session cipher — handshake incomplete
What it means
sendEnvelope() requires this.cipher, the symmetric XChaCha20-Poly1305 TunnelCipher that exists only after the sealed handshake is acked by the core. The socket 'disconnect' handler nulls cipher (along with staticDhKey and clientEphemeralKeypair), so the error means: a call was attempted before the first handshake completed, or after a disconnect before the automatic reconnection re-ran the handshake.
Solutions
- Always go through the transport's connect path (or the TransportManager race) and await readiness before the first call
- On this error, await the transport's connect/handshake again (ensureConnected re-runs it because disconnect nulled _connectPromise) then retry the call
- Call manager.reset() to rebuild both transports if the tunnel keeps flapping
Example fix
// before this.socket.emit(...); // raw send, cipher may be null after a disconnect // after await this.ensureConnected(); // re-runs handshake, re-derives cipher await this.sendEnvelope(envelope);
Defensive patterns
Strategy: validation
Validate before calling
// Handshake-readiness gate: never emit frames directly — go through call(),
// which awaits the connect/handshake promise before sending:
const result = await tunnel.call('openhuman.ping', {}); // connect + handshake + RPC
// If you drive the socket yourself, mirror the internal state:
if (!tunnel.isConnected()) await tunnel.connect(); Try / catch
try {
await tunnel.call(method, params);
} catch (err) {
if (err instanceof Error && err.message.includes('no session cipher')) {
await sleep(500); // allow socket.io reconnect + re-handshake
return await tunnel.call(method, params);
}
throw err;
} Prevention
- Always await the connect/handshake path before the first RPC on a fresh tunnel
- After laptop-sleep/network-flap, expect one reconnect cycle before calls succeed — back off briefly
- Use manager.reset() when the tunnel flaps repeatedly rather than retrying forever
When it happens
Trigger: Calling transport.call() without awaiting the connect/handshake promise first; a socket.io disconnect (network blip, laptop sleep) landing between calls — the next sendEnvelope hits a nulled cipher until reconnect + tunnel:connected + handshake ack complete again.
Common situations: Race at startup where UI issues RPC before the tunnel is ready; mobile clients backgrounding the app (socket drops) and resuming instantly; flaky Wi-Fi causing rapid disconnect/reconnect cycles.
Understand the failure class
- SSL/TLS and certificate errors — how TLS handshakes and certificate validation fail.
Related errors
- [tunnel] no device keypair
- Ed25519 derivation path must be fully hardened
- encrypted payload too short
- encryption key must decode to 32 raw bytes
- Failed to derive private key for path
AI-assisted analysis of tinyhumansai/openhuman@7491200858 (2026-08-17).
Data as JSON: /api/errors/30cc10288c7abe03.
Report an issue: GitHub.
Appendix: source
Thrown at app/src/services/transport/TunnelTransport.ts:322
this.streams.delete(requestId);
}
return;
}
if (kind === 'response') {
const pending = this.pending.get(requestId);
if (!pending) return;
clearTimeout(pending.timeoutId);
this.pending.delete(requestId);
pending.resolve(envelope.payload);
return;
}
}
// -- send ------------------------------------------------------------------
private async sendEnvelope(envelope: Envelope): Promise<void> {
if (!this.cipher) throw new Error('[tunnel] no session cipher — handshake incomplete');
await this.rateLimiter.consume();
const chunks = chunk(envelope);
for (const raw of chunks) {
const encrypted = this.cipher.seal(raw);
const frameB64 = base64urlEncode(encrypted);
this.socket!.emit('tunnel:frame', { channelId: this.channelId, payload: frameB64 });
}
log(
'[tunnel] sent %s requestId=%s chunks=%d',
envelope.kind,
envelope.requestId,
chunks.length
);
}
View on GitHub (pinned to 7491200858)