From 940f19d8c43a53af1690e5860a25a7219a283b65 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 17 Jul 2026 03:42:01 +0000 Subject: [PATCH] fix(browser): supported authenticated cmux tcp relays Dialed loopback CMUX_SOCKET_PATH endpoints over TCP and completed the cmux relay HMAC challenge before sending JSON-RPC requests. Loaded relay credentials from the session environment or the per-port cmux auth file while preserving Unix socket behavior. Fixes #5788 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/tools/browser/cmux/socket-client.ts | 142 +++++++++++++- .../test/tools/browser-cmux-socket.test.ts | 175 +++++++++++++++--- 3 files changed, 291 insertions(+), 30 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5406325c3..38ee82923 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - `retry.fallbackChains` wildcards now support id-prefixed targets and keys: a chain entry like `"openrouter/google/*"` re-prefixes the failing model's bare id (`google-antigravity/gemini-x` → `openrouter/google/gemini-x`), a plain `"provider/*"` entry falling back *from* an aggregator strips the vendor prefix when the target provider only knows the bare id (`openrouter/google/x` → `google-vertex/x`), and an id-prefixed key (`"openrouter/google/*"`) scopes a chain to that provider's ids under the prefix. +### Fixed + +- Fixed the cmux browser backend failing inside `cmux ssh` sessions by dialing loopback `CMUX_SOCKET_PATH` values over TCP and completing the relay HMAC-SHA256 challenge-response with credentials from the session environment or `~/.cmux/relay/.auth` ([#5788](https://github.com/can1357/oh-my-pi/issues/5788)). + ## [17.0.1] - 2026-07-16 ### Changed diff --git a/packages/coding-agent/src/tools/browser/cmux/socket-client.ts b/packages/coding-agent/src/tools/browser/cmux/socket-client.ts index 2d5f215dc..4812f73c8 100644 --- a/packages/coding-agent/src/tools/browser/cmux/socket-client.ts +++ b/packages/coding-agent/src/tools/browser/cmux/socket-client.ts @@ -1,9 +1,12 @@ import { randomUUID } from "node:crypto"; import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; import { ToolError } from "../../tool-errors"; const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const UTF8 = new TextEncoder(); type RequestJob = { method: string; @@ -25,6 +28,37 @@ type CmuxErrorPayload = { details?: unknown; }; +type RelayEndpoint = { + host: string; + port: number; +}; + +type RelayCredentials = { + relayId: string; + relayToken: Uint8Array; +}; + +function parseRelayCredentials(relayIdValue: unknown, relayTokenValue: unknown): RelayCredentials | null { + if (typeof relayIdValue !== "string" || typeof relayTokenValue !== "string") { + return null; + } + const relayId = relayIdValue.trim(); + const relayTokenHex = relayTokenValue.trim(); + if ( + relayId.length === 0 || + relayTokenHex.length === 0 || + relayTokenHex.length % 2 !== 0 || + !/^[0-9a-f]+$/i.test(relayTokenHex) + ) { + return null; + } + const relayToken = new Uint8Array(new ArrayBuffer(relayTokenHex.length / 2)); + for (let index = 0; index < relayToken.length; index++) { + relayToken[index] = Number.parseInt(relayTokenHex.slice(index * 2, index * 2 + 2), 16); + } + return { relayId, relayToken }; +} + export function formatCmuxError(error: CmuxErrorPayload | undefined): string { const code = typeof error?.code === "string" && error.code.length > 0 ? error.code : "error"; const message = typeof error?.message === "string" && error.message.length > 0 ? error.message : "cmux error"; @@ -35,6 +69,8 @@ export function formatCmuxError(error: CmuxErrorPayload | undefined): string { export class CmuxSocketClient { readonly #socketPath: string; readonly #password: string | undefined; + readonly #relayId: string | undefined; + readonly #relayToken: string | undefined; #socket: net.Socket | null = null; #connectPromise: Promise | null = null; #connected = false; @@ -45,9 +81,11 @@ export class CmuxSocketClient { #activeJob: RequestJob | null = null; #pumping = false; - constructor(opts: { socketPath: string; password?: string }) { + constructor(opts: { socketPath: string; password?: string; relayId?: string; relayToken?: string }) { this.#socketPath = opts.socketPath; this.#password = opts.password; + this.#relayId = opts.relayId ?? process.env.CMUX_RELAY_ID; + this.#relayToken = opts.relayToken ?? process.env.CMUX_RELAY_TOKEN; } async connect(): Promise { @@ -101,7 +139,11 @@ export class CmuxSocketClient { } async #openSocket(): Promise { - const socket = net.createConnection({ path: this.#socketPath }); + const relayEndpoint = this.#parseRelayEndpoint(); + const relayCredentials = relayEndpoint ? await this.#loadRelayCredentials(relayEndpoint) : null; + const socket = relayEndpoint + ? net.createConnection({ host: relayEndpoint.host, port: relayEndpoint.port }) + : net.createConnection({ path: this.#socketPath }); this.#socket = socket; this.#buffer = ""; socket.setEncoding("utf8"); @@ -111,13 +153,16 @@ export class CmuxSocketClient { try { await this.#waitForConnect(socket); - this.#connected = true; + if (relayEndpoint && relayCredentials) { + await this.#authenticateRelay(relayEndpoint, relayCredentials); + } if (this.#password) { const line = await this.#sendLine(`auth ${this.#password}`, DEFAULT_CONNECT_TIMEOUT_MS); if (line.startsWith("ERROR:") && !line.includes("Unknown command 'auth'")) { throw new ToolError(line); } } + this.#connected = true; } catch (err) { this.#connected = false; socket.destroy(); @@ -128,6 +173,97 @@ export class CmuxSocketClient { } } + #parseRelayEndpoint(): RelayEndpoint | null { + const value = this.#socketPath.trim(); + if (value.length === 0 || value.startsWith("/")) { + return null; + } + const match = /^(127\.0\.0\.1|localhost):([0-9]+)$/.exec(value); + if (!match) { + return null; + } + const port = Number.parseInt(match[2] ?? "", 10); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + return null; + } + return { host: "127.0.0.1", port }; + } + + async #loadRelayCredentials(endpoint: RelayEndpoint): Promise { + const environmentCredentials = parseRelayCredentials(this.#relayId, this.#relayToken); + if (environmentCredentials) { + return environmentCredentials; + } + + const authPath = path.join(os.homedir(), ".cmux", "relay", `${endpoint.port}.auth`); + let payload: unknown; + try { + payload = await Bun.file(authPath).json(); + } catch { + throw new ToolError( + `Missing cmux relay auth metadata for ${endpoint.host}:${endpoint.port}; set CMUX_RELAY_ID/CMUX_RELAY_TOKEN or restore ~/.cmux/relay/${endpoint.port}.auth`, + ); + } + const relayId = payload && typeof payload === "object" && "relay_id" in payload ? payload.relay_id : undefined; + const relayToken = + payload && typeof payload === "object" && "relay_token" in payload ? payload.relay_token : undefined; + const fileCredentials = parseRelayCredentials(relayId, relayToken); + if (!fileCredentials) { + throw new ToolError(`Invalid cmux relay auth metadata in ~/.cmux/relay/${endpoint.port}.auth`); + } + return fileCredentials; + } + + async #authenticateRelay(endpoint: RelayEndpoint, credentials: RelayCredentials): Promise { + const challengeLine = await this.#nextLine(DEFAULT_CONNECT_TIMEOUT_MS); + let challenge: unknown; + try { + challenge = JSON.parse(challengeLine); + } catch { + throw new ToolError(`Invalid cmux relay authentication challenge from ${endpoint.host}:${endpoint.port}`); + } + if ( + !challenge || + typeof challenge !== "object" || + !("protocol" in challenge) || + challenge.protocol !== "cmux-relay-auth" || + !("version" in challenge) || + typeof challenge.version !== "number" || + !Number.isInteger(challenge.version) || + !("relay_id" in challenge) || + challenge.relay_id !== credentials.relayId || + !("nonce" in challenge) || + typeof challenge.nonce !== "string" || + challenge.nonce.length === 0 + ) { + throw new ToolError(`Invalid cmux relay authentication challenge from ${endpoint.host}:${endpoint.port}`); + } + + const message = `relay_id=${challenge.relay_id}\nnonce=${challenge.nonce}\nversion=${challenge.version}`; + const key = await globalThis.crypto.subtle.importKey( + "raw", + credentials.relayToken, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const mac = await globalThis.crypto.subtle.sign("HMAC", key, UTF8.encode(message)); + const authLine = JSON.stringify({ + relay_id: credentials.relayId, + mac: Buffer.from(mac).toString("hex"), + }); + const responseLine = await this.#sendLine(authLine, DEFAULT_CONNECT_TIMEOUT_MS); + let response: unknown; + try { + response = JSON.parse(responseLine); + } catch { + throw new ToolError(`Cmux relay authentication failed for ${endpoint.host}:${endpoint.port}`); + } + if (!response || typeof response !== "object" || !("ok" in response) || response.ok !== true) { + throw new ToolError(`Cmux relay authentication failed for ${endpoint.host}:${endpoint.port}`); + } + } + #waitForConnect(socket: net.Socket): Promise { const { promise, resolve, reject } = Promise.withResolvers(); const timer = setTimeout(() => { diff --git a/packages/coding-agent/test/tools/browser-cmux-socket.test.ts b/packages/coding-agent/test/tools/browser-cmux-socket.test.ts index d3f4fc814..b3fb7f5cc 100644 --- a/packages/coding-agent/test/tools/browser-cmux-socket.test.ts +++ b/packages/coding-agent/test/tools/browser-cmux-socket.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, describe, expect, it, spyOn, vi } from "bun:test"; +import * as fs from "node:fs/promises"; import * as net from "node:net"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { CmuxSocketClient } from "@oh-my-pi/pi-coding-agent/tools/browser"; +import * as os from "node:os"; +import * as path from "node:path"; +import { CmuxSocketClient } from "@oh-my-pi/pi-coding-agent/tools/browser/cmux/socket-client"; import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; type RequestLine = { @@ -13,43 +13,84 @@ type RequestLine = { jsonrpc?: unknown; }; +function readSocketLines(socket: net.Socket, handleLine: (line: string, socket: net.Socket) => void): void { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", chunk => { + buffer += String(chunk); + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + handleLine(line, socket); + } + }); +} + async function withSocketServer( handleLine: (line: string, socket: net.Socket) => void, run: (socketPath: string) => Promise, ): Promise { - const dir = await mkdtemp(join(tmpdir(), "cmux-browser-test-")); - const socketPath = join(dir, "cmux.sock"); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-browser-test-")); + const socketPath = path.join(dir, "cmux.sock"); const server = net.createServer(socket => { - socket.setEncoding("utf8"); - let buffer = ""; - socket.on("data", chunk => { - buffer += String(chunk); - for (;;) { - const newline = buffer.indexOf("\n"); - if (newline < 0) break; - const line = buffer.slice(0, newline); - buffer = buffer.slice(newline + 1); - handleLine(line, socket); - } - }); + readSocketLines(socket, handleLine); }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve(); - }); + const listening = Promise.withResolvers(); + server.once("error", listening.reject); + server.listen(socketPath, () => { + server.off("error", listening.reject); + listening.resolve(); }); + await listening.promise; try { await run(socketPath); } finally { - await new Promise(resolve => server.close(() => resolve())); - await rm(dir, { recursive: true, force: true }); + const closed = Promise.withResolvers(); + server.close(() => closed.resolve()); + await closed.promise; + await fs.rm(dir, { recursive: true, force: true }); } } +async function withTcpRelayServer( + challenge: Record, + handleLine: (line: string, socket: net.Socket) => void, + run: (socketPath: string, port: number) => Promise, +): Promise { + const server = net.createServer(socket => { + readSocketLines(socket, handleLine); + socket.write(`${JSON.stringify(challenge)}\n`); + }); + const listening = Promise.withResolvers(); + server.once("error", listening.reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", listening.reject); + listening.resolve(); + }); + await listening.promise; + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("TCP relay server did not expose an address"); + } + + try { + await run(`127.0.0.1:${address.port}`, address.port); + } finally { + const closed = Promise.withResolvers(); + server.close(() => closed.resolve()); + await closed.promise; + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("CmuxSocketClient", () => { it("authenticates, frames JSON requests, and returns the result", async () => { const lines: string[] = []; @@ -93,6 +134,86 @@ describe("CmuxSocketClient", () => { ); }); + it("authenticates a TCP relay before forwarding JSON requests", async () => { + const lines: string[] = []; + await withTcpRelayServer( + { protocol: "cmux-relay-auth", version: 1, relay_id: "relay-1", nonce: "nonce-1" }, + (line, socket) => { + lines.push(line); + if (lines.length === 1) { + socket.write(`${JSON.stringify({ ok: true })}\n`); + return; + } + socket.write(`${JSON.stringify({ ok: true, result: { connected: true } })}\n`); + }, + async socketPath => { + const client = new CmuxSocketClient({ + socketPath, + relayId: "relay-1", + relayToken: "00112233445566778899aabbccddeeff", + }); + try { + expect(await client.request("browser.navigate", { url: "https://example.com" })).toEqual({ + connected: true, + }); + } finally { + client.close(); + } + }, + ); + + expect(JSON.parse(lines[0] ?? "")).toEqual({ + relay_id: "relay-1", + mac: "f99276589f826dcb777c2e0137a80ff5cb2bdb7ac72b55b3080d0febdf18c414", + }); + expect(JSON.parse(lines[1] ?? "")).toEqual({ + id: expect.any(String), + method: "browser.navigate", + params: { url: "https://example.com" }, + }); + }); + + it("loads TCP relay credentials from the cmux auth file", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-relay-home-")); + spyOn(os, "homedir").mockReturnValue(home); + const lines: string[] = []; + try { + await withTcpRelayServer( + { protocol: "cmux-relay-auth", version: 1, relay_id: "relay-1", nonce: "nonce-1" }, + (line, socket) => { + lines.push(line); + if (lines.length === 1) { + socket.write(`${JSON.stringify({ ok: true })}\n`); + return; + } + socket.write(`${JSON.stringify({ ok: true, result: {} })}\n`); + }, + async (socketPath, port) => { + await Bun.write( + path.join(home, ".cmux", "relay", `${port}.auth`), + JSON.stringify({ + relay_id: "relay-1", + relay_token: "00112233445566778899aabbccddeeff", + }), + ); + const client = new CmuxSocketClient({ socketPath, relayId: "", relayToken: "" }); + try { + await client.request("browser.get_url", {}); + } finally { + client.close(); + } + }, + ); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + + expect(JSON.parse(lines[0] ?? "")).toEqual({ + relay_id: "relay-1", + mac: "f99276589f826dcb777c2e0137a80ff5cb2bdb7ac72b55b3080d0febdf18c414", + }); + }); + it("throws ToolError for ok:false not_supported responses", async () => { await withSocketServer( (_line, socket) => {