Merge PR #5789: fix(browser): support authenticated cmux TCP relays (@roboomp)

This commit is contained in:
can1357
2026-07-18 20:12:47 +02:00
3 changed files with 292 additions and 30 deletions
+5
View File
@@ -113,6 +113,11 @@
- Fixed the status line loop indicator to distinguish waiting, running, and paused states and show the remaining loop budget ([#5832](https://github.com/can1357/oh-my-pi/pull/5832) by [@wolfiesch](https://github.com/wolfiesch)).
## [17.0.3] - 2026-07-17
### 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/<port>.auth` ([#5788](https://github.com/can1357/oh-my-pi/issues/5788)).
## [17.0.1] - 2026-07-16
### Changed
@@ -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<ArrayBuffer>;
};
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<void> | 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<void> {
@@ -101,7 +139,11 @@ export class CmuxSocketClient {
}
async #openSocket(): Promise<void> {
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<RelayCredentials> {
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<void> {
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<void> {
const { promise, resolve, reject } = Promise.withResolvers<void>();
const timer = setTimeout(() => {
@@ -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<void>,
): Promise<void> {
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<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, () => {
server.off("error", reject);
resolve();
});
const listening = Promise.withResolvers<void>();
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<void>(resolve => server.close(() => resolve()));
await rm(dir, { recursive: true, force: true });
const closed = Promise.withResolvers<void>();
server.close(() => closed.resolve());
await closed.promise;
await fs.rm(dir, { recursive: true, force: true });
}
}
async function withTcpRelayServer(
challenge: Record<string, unknown>,
handleLine: (line: string, socket: net.Socket) => void,
run: (socketPath: string, port: number) => Promise<void>,
): Promise<void> {
const server = net.createServer(socket => {
readSocketLines(socket, handleLine);
socket.write(`${JSON.stringify(challenge)}\n`);
});
const listening = Promise.withResolvers<void>();
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<void>();
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) => {