{"record":{"id":"30cc10288c7abe03","repo":"tinyhumansai/openhuman","slug":"tunnel-no-session-cipher-handshake-incomplete","errorCode":null,"errorMessage":"[tunnel] no session cipher — handshake incomplete","messagePattern":"\\[tunnel\\] no session cipher — handshake incomplete","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"app/src/services/transport/TunnelTransport.ts","lineNumber":322,"sourceCode":"        this.streams.delete(requestId);\n      }\n      return;\n    }\n\n    if (kind === 'response') {\n      const pending = this.pending.get(requestId);\n      if (!pending) return;\n      clearTimeout(pending.timeoutId);\n      this.pending.delete(requestId);\n      pending.resolve(envelope.payload);\n      return;\n    }\n  }\n\n  // -- send ------------------------------------------------------------------\n\n  private async sendEnvelope(envelope: Envelope): Promise<void> {\n    if (!this.cipher) throw new Error('[tunnel] no session cipher — handshake incomplete');\n\n    await this.rateLimiter.consume();\n\n    const chunks = chunk(envelope);\n    for (const raw of chunks) {\n      const encrypted = this.cipher.seal(raw);\n      const frameB64 = base64urlEncode(encrypted);\n      this.socket!.emit('tunnel:frame', { channelId: this.channelId, payload: frameB64 });\n    }\n\n    log(\n      '[tunnel] sent %s requestId=%s chunks=%d',\n      envelope.kind,\n      envelope.requestId,\n      chunks.length\n    );\n  }\n","sourceCodeStart":304,"sourceCodeEnd":340,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/a221052e0df5b1f7598fceba7329fd1af95d6699/app/src/services/transport/TunnelTransport.ts#L304-L340","documentation":"sendEnvelope seals every frame with this.cipher, which exists only after the encrypted handshake completes and both sides derived the session key. This invariant fires when a request is sent before connect() resolved, or on a transport whose handshake failed and was reused anyway.","triggerScenarios":"Calling call()/request() on a TunnelTransport whose connect() promise has not resolved (or has rejected) — fire-and-forget connect() followed immediately by send; reconnect logic that swaps the socket but not the cipher.","commonSituations":"Missing `await transport.connect()`; racing requests fired right after construction; reusing a transport instance after a connect_error.","solutions":["Always await connect() before the first call, or route through TransportManager which pings first","On handshake/connect failure, close and discard the transport and build a fresh one","Queue early requests until the handshake completes instead of sending immediately"],"exampleFix":"// before\nvoid transport.connect();\nawait transport.call('openhuman.ping', {});\n\n// after\nawait transport.connect();\nawait transport.call('openhuman.ping', {});","handlingStrategy":"validation","validationCode":"// Gate sends on a completed round-trip instead of assuming connection:\nconst t = await manager.getTransport(); // ping already succeeded → handshake done\nawait t.call('openhuman.ping', {});","typeGuard":null,"tryCatchPattern":"Catch 'handshake incomplete', close() the broken instance, construct a fresh transport, await connect(), and retry the request once — never resend on the same instance.","preventionTips":["Never call a transport method before connect() resolves","Treat handshake failure as fatal for that transport instance — replace it","Queue requests that arrive during handshake rather than sending eagerly"],"tags":["transport","tunnel","handshake","ordering","crypto"],"backgroundTag":null,"analyzedSha":"a221052e0df5b1f7598fceba7329fd1af95d6699","analyzedAt":"2026-08-16T12:47:06.542Z","schemaVersion":2},"datasetVersion":"2026-08-16T13:17:31.715Z"}