Merge PR #5789: fix(browser): support authenticated cmux TCP relays (@roboomp)
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user