* Add claude-teams, omo, and __tmux-compat to Go relay CLI These commands previously only existed in the Swift CLI which uses Unix domain sockets and can't connect over TCP relay. The Go relay CLI already handles TCP connections, so adding the commands here makes them work inside `cmux ssh` sessions. - `cmux claude-teams`: creates tmux shim scripts, configures environment (fake TMUX/TMUX_PANE, socket path, workspace/surface IDs), and execs into `claude --teammate-mode auto` - `cmux omo`: same pattern for OpenCode with terminal-notifier shim - `cmux __tmux-compat`: translates tmux commands (split-window, send-keys, capture-pane, display-message, list-panes, etc.) into cmux JSON-RPC calls over the relay socket. Includes main-vertical layout tracking, wait-for signaling, and format string rendering. * Fix: search original PATH before environment modification findExecutable was called after configureAgentEnvironment prepended the shim directory to PATH. The Swift CLI searches the original PATH before modification. Renamed to findExecutableInPath with explicit PATH arg and moved the search before configureAgentEnvironment. * Fix cmux omo hang and port oh-my-opencode plugin setup Root cause: socketRoundTripV2 had no read timeout. When connecting to a stale relay port (accepted TCP but never responded), the read blocked forever. This caused getFocusedContext to hang, blocking agent launch. Fixes: - Add 15s read deadline to socketRoundTripV2 (affects all v2 RPC calls) - Add 5s timeout to getFocusedContext so agent launch proceeds even if system.identify is slow - Port omoEnsurePlugin from Swift: creates shadow config dir, adds oh-my-opencode to plugin list, symlinks node_modules/package.json, installs plugin via bun/npm if missing, configures tmux settings (enabled=true, lower min widths), sets OPENCODE_CONFIG_DIR * Fix: use bun as runtime for node-script opencode when node is missing opencode is installed via bun as a #!/usr/bin/env node script, but on some systems (like the macmini) bun is installed without a standalone node binary. Detect node scripts and fall back to bun as the runtime since bun is node-compatible. * Fix subagent pane theme: preserve COLORTERM, keep TERM_PROGRAM The cmux ssh bootstrap exports COLORTERM=truecolor and TERM_PROGRAM=ghostty. Our configureAgentEnvironment was unsetting TERM_PROGRAM and not setting COLORTERM, causing subagent panes (created via split-window) to lose truecolor detection and render with wrong theme colors. * Restore TERM_PROGRAM unset, keep COLORTERM=truecolor * Force dark colorScheme in opencode shadow config for SSH * Remove hardcoded dark colorScheme, let opencode detect naturally * Detect system color scheme for opencode over SSH * Remove color scheme detection workaround, let opencode handle natively --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
774 lines
23 KiB
Go
774 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type relayAuthState struct {
|
|
RelayID string `json:"relay_id"`
|
|
RelayToken string `json:"relay_token"`
|
|
}
|
|
|
|
// protocolVersion indicates whether a command uses the v1 text or v2 JSON-RPC protocol.
|
|
type protocolVersion int
|
|
|
|
const (
|
|
protoV1 protocolVersion = iota
|
|
protoV2
|
|
)
|
|
|
|
// commandSpec describes a single CLI command and how to relay it.
|
|
type commandSpec struct {
|
|
name string // CLI command name (e.g. "ping", "new-window")
|
|
proto protocolVersion // v1 text or v2 JSON-RPC
|
|
v1Cmd string // v1: literal command string sent over the socket
|
|
v2Method string // v2: JSON-RPC method name
|
|
// flagKeys lists parameter keys this command accepts.
|
|
// They are extracted from --key flags and added to params.
|
|
flagKeys []string
|
|
// noParams means the command takes no parameters at all.
|
|
noParams bool
|
|
// paramKeyOverrides remaps specific flags for compatibility aliases.
|
|
paramKeyOverrides map[string]string
|
|
// defaultParams are applied before flags/env fallbacks.
|
|
defaultParams map[string]any
|
|
}
|
|
|
|
var commands = []commandSpec{
|
|
// V1 text protocol commands
|
|
{name: "ping", proto: protoV1, v1Cmd: "ping", noParams: true},
|
|
{name: "new-window", proto: protoV1, v1Cmd: "new_window", noParams: true},
|
|
{name: "current-window", proto: protoV1, v1Cmd: "current_window", noParams: true},
|
|
{name: "close-window", proto: protoV1, v1Cmd: "close_window", flagKeys: []string{"window"}},
|
|
{name: "focus-window", proto: protoV1, v1Cmd: "focus_window", flagKeys: []string{"window"}},
|
|
{name: "list-windows", proto: protoV1, v1Cmd: "list_windows", noParams: true},
|
|
|
|
// V2 JSON-RPC commands
|
|
{name: "capabilities", proto: protoV2, v2Method: "system.capabilities", noParams: true},
|
|
{name: "list-workspaces", proto: protoV2, v2Method: "workspace.list", noParams: true},
|
|
{name: "new-workspace", proto: protoV2, v2Method: "workspace.create", flagKeys: []string{"command", "working-directory", "name"}},
|
|
{name: "close-workspace", proto: protoV2, v2Method: "workspace.close", flagKeys: []string{"workspace"}},
|
|
{name: "select-workspace", proto: protoV2, v2Method: "workspace.select", flagKeys: []string{"workspace"}},
|
|
{name: "current-workspace", proto: protoV2, v2Method: "workspace.current", noParams: true},
|
|
{name: "list-panels", proto: protoV2, v2Method: "surface.list", flagKeys: []string{"workspace"}},
|
|
{name: "focus-panel", proto: protoV2, v2Method: "surface.focus", flagKeys: []string{"panel", "workspace"}, paramKeyOverrides: map[string]string{"panel": "surface_id"}},
|
|
{name: "list-panes", proto: protoV2, v2Method: "pane.list", flagKeys: []string{"workspace"}},
|
|
{name: "list-pane-surfaces", proto: protoV2, v2Method: "pane.surfaces", flagKeys: []string{"pane"}},
|
|
{name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace", "direction", "type", "url"}, defaultParams: map[string]any{"direction": "right"}},
|
|
{name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane", "type", "url"}},
|
|
{name: "new-split", proto: protoV2, v2Method: "surface.split", flagKeys: []string{"surface", "direction"}},
|
|
{name: "close-surface", proto: protoV2, v2Method: "surface.close", flagKeys: []string{"surface"}},
|
|
{name: "send", proto: protoV2, v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}},
|
|
{name: "send-key", proto: protoV2, v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}},
|
|
{name: "notify", proto: protoV2, v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}},
|
|
{name: "refresh-surfaces", proto: protoV2, v2Method: "surface.refresh", noParams: true},
|
|
}
|
|
|
|
var commandIndex map[string]*commandSpec
|
|
|
|
func init() {
|
|
commandIndex = make(map[string]*commandSpec, len(commands))
|
|
for i := range commands {
|
|
commandIndex[commands[i].name] = &commands[i]
|
|
}
|
|
}
|
|
|
|
// runCLI is the entry point for the "cli" subcommand (or busybox "cmux" invocation).
|
|
func runCLI(args []string) int {
|
|
socketPath := os.Getenv("CMUX_SOCKET_PATH")
|
|
|
|
// Parse global flags
|
|
var jsonOutput bool
|
|
var remaining []string
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--socket":
|
|
if i+1 >= len(args) {
|
|
fmt.Fprintln(os.Stderr, "cmux: --socket requires a path")
|
|
return 2
|
|
}
|
|
socketPath = args[i+1]
|
|
i++
|
|
case "--json":
|
|
jsonOutput = true
|
|
case "--help", "-h":
|
|
cliUsage()
|
|
return 0
|
|
default:
|
|
remaining = append(remaining, args[i:]...)
|
|
goto doneFlags
|
|
}
|
|
}
|
|
doneFlags:
|
|
|
|
if len(remaining) == 0 {
|
|
cliUsage()
|
|
return 2
|
|
}
|
|
cmdName := remaining[0]
|
|
cmdArgs := remaining[1:]
|
|
if cmdName == "help" {
|
|
cliUsage()
|
|
return 0
|
|
}
|
|
|
|
// refreshAddr is set when the address came from socket_addr file (not env/flag),
|
|
// allowing one stale-address refresh if another workspace has replaced socket_addr.
|
|
var refreshAddr func() string
|
|
if socketPath == "" {
|
|
socketPath = readSocketAddrFile()
|
|
refreshAddr = readSocketAddrFile
|
|
}
|
|
if socketPath == "" {
|
|
fmt.Fprintln(os.Stderr, "cmux: CMUX_SOCKET_PATH not set and --socket not provided")
|
|
return 1
|
|
}
|
|
|
|
// Special case: "rpc" passthrough
|
|
if cmdName == "rpc" {
|
|
return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr)
|
|
}
|
|
|
|
// Browser subcommand delegation
|
|
if cmdName == "browser" {
|
|
return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr)
|
|
}
|
|
|
|
// Agent launch commands
|
|
if cmdName == "claude-teams" {
|
|
return runClaudeTeamsRelay(socketPath, cmdArgs, refreshAddr)
|
|
}
|
|
if cmdName == "omo" {
|
|
return runOMORelay(socketPath, cmdArgs, refreshAddr)
|
|
}
|
|
|
|
// Tmux compatibility layer (used by agent shims)
|
|
if cmdName == "__tmux-compat" {
|
|
return runTmuxCompat(socketPath, cmdArgs, refreshAddr)
|
|
}
|
|
|
|
spec, ok := commandIndex[cmdName]
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "cmux: unknown command %q\n", cmdName)
|
|
return 2
|
|
}
|
|
|
|
switch spec.proto {
|
|
case protoV1:
|
|
return execV1(socketPath, spec, cmdArgs, refreshAddr)
|
|
case protoV2:
|
|
return execV2(socketPath, spec, cmdArgs, jsonOutput, refreshAddr)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "cmux: internal error: unknown protocol for %q\n", cmdName)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
// execV1 sends a v1 text command over the socket.
|
|
func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr func() string) int {
|
|
cmd := spec.v1Cmd
|
|
|
|
if !spec.noParams {
|
|
parsed, err := parseFlags(args, spec.flagKeys)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
|
|
return 2
|
|
}
|
|
for _, key := range spec.flagKeys {
|
|
if val, ok := parsed.flags[key]; ok {
|
|
cmd += " " + val
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, err := socketRoundTrip(socketPath, cmd, refreshAddr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Print(resp)
|
|
if !strings.HasSuffix(resp, "\n") {
|
|
fmt.Println()
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// execV2 sends a v2 JSON-RPC request over the socket.
|
|
func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int {
|
|
params := make(map[string]any, len(spec.defaultParams))
|
|
for key, value := range spec.defaultParams {
|
|
params[key] = value
|
|
}
|
|
|
|
if !spec.noParams {
|
|
parsed, err := parseFlags(args, spec.flagKeys)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
|
|
return 2
|
|
}
|
|
// Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate)
|
|
for _, key := range spec.flagKeys {
|
|
if val, ok := parsed.flags[key]; ok {
|
|
paramKey := flagToParamKey(key)
|
|
if override, ok := spec.paramKeyOverrides[key]; ok {
|
|
paramKey = override
|
|
}
|
|
params[paramKey] = val
|
|
}
|
|
}
|
|
|
|
// First positional arg is used as initial_command if --command wasn't given
|
|
if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 {
|
|
params["initial_command"] = parsed.positional[0]
|
|
}
|
|
|
|
applyWorkspaceEnvFallback(params)
|
|
applySurfaceEnvFallback(params)
|
|
}
|
|
|
|
resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
if jsonOutput {
|
|
fmt.Println(resp)
|
|
} else {
|
|
fmt.Println(defaultRelayOutput(resp))
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// runRPC sends an arbitrary JSON-RPC method with optional JSON params.
|
|
func runRPC(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "cmux rpc: requires a method name")
|
|
return 2
|
|
}
|
|
method := args[0]
|
|
var params map[string]any
|
|
if len(args) > 1 {
|
|
if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil {
|
|
fmt.Fprintf(os.Stderr, "cmux rpc: invalid JSON params: %v\n", err)
|
|
return 2
|
|
}
|
|
}
|
|
|
|
resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Println(resp)
|
|
return 0
|
|
}
|
|
|
|
// runBrowserRelay handles "cmux browser <subcommand>" by mapping to browser.* v2 methods.
|
|
func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "cmux browser: requires a subcommand (open, navigate, back, forward, reload, get-url)")
|
|
return 2
|
|
}
|
|
|
|
sub := args[0]
|
|
subArgs := args[1:]
|
|
|
|
var method string
|
|
var flagKeys []string
|
|
var allowPositionalURL bool
|
|
var useWorkspaceEnv bool
|
|
var useSurfaceEnv bool
|
|
switch sub {
|
|
case "open", "open-split", "new":
|
|
method = "browser.open_split"
|
|
flagKeys = []string{"url", "workspace", "surface"}
|
|
allowPositionalURL = true
|
|
useWorkspaceEnv = true
|
|
case "navigate":
|
|
method = "browser.navigate"
|
|
flagKeys = []string{"url", "surface"}
|
|
allowPositionalURL = true
|
|
useSurfaceEnv = true
|
|
case "back":
|
|
method = "browser.back"
|
|
flagKeys = []string{"surface"}
|
|
useSurfaceEnv = true
|
|
case "forward":
|
|
method = "browser.forward"
|
|
flagKeys = []string{"surface"}
|
|
useSurfaceEnv = true
|
|
case "reload":
|
|
method = "browser.reload"
|
|
flagKeys = []string{"surface"}
|
|
useSurfaceEnv = true
|
|
case "get-url":
|
|
method = "browser.url.get"
|
|
flagKeys = []string{"surface"}
|
|
useSurfaceEnv = true
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub)
|
|
return 2
|
|
}
|
|
|
|
params := make(map[string]any)
|
|
parsed, err := parseFlags(subArgs, flagKeys)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cmux browser: %v\n", err)
|
|
return 2
|
|
}
|
|
for _, key := range flagKeys {
|
|
if val, ok := parsed.flags[key]; ok {
|
|
paramKey := flagToParamKey(key)
|
|
params[paramKey] = val
|
|
}
|
|
}
|
|
if allowPositionalURL {
|
|
if _, ok := params["url"]; !ok && len(parsed.positional) > 0 {
|
|
params["url"] = strings.Join(parsed.positional, " ")
|
|
}
|
|
}
|
|
if useWorkspaceEnv {
|
|
applyWorkspaceEnvFallback(params)
|
|
}
|
|
if useSurfaceEnv {
|
|
applySurfaceEnvFallback(params)
|
|
}
|
|
|
|
resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cmux: %v\n", err)
|
|
return 1
|
|
}
|
|
if jsonOutput {
|
|
fmt.Println(resp)
|
|
} else {
|
|
fmt.Println(defaultRelayOutput(resp))
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func applyWorkspaceEnvFallback(params map[string]any) {
|
|
if _, ok := params["workspace_id"]; ok {
|
|
return
|
|
}
|
|
if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" {
|
|
params["workspace_id"] = envWs
|
|
}
|
|
}
|
|
|
|
func applySurfaceEnvFallback(params map[string]any) {
|
|
if _, ok := params["surface_id"]; ok {
|
|
return
|
|
}
|
|
if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" {
|
|
params["surface_id"] = envSf
|
|
}
|
|
}
|
|
|
|
func defaultRelayOutput(resp string) string {
|
|
var result any
|
|
if err := json.Unmarshal([]byte(resp), &result); err != nil {
|
|
trimmed := strings.TrimSpace(resp)
|
|
if trimmed == "" {
|
|
return "OK"
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
if relayResultIsEmpty(result) {
|
|
return "OK"
|
|
}
|
|
|
|
switch typed := result.(type) {
|
|
case string:
|
|
return typed
|
|
default:
|
|
encoded, err := json.MarshalIndent(typed, "", " ")
|
|
if err != nil {
|
|
return "OK"
|
|
}
|
|
return string(encoded)
|
|
}
|
|
}
|
|
|
|
func relayResultIsEmpty(result any) bool {
|
|
switch typed := result.(type) {
|
|
case nil:
|
|
return true
|
|
case map[string]any:
|
|
return len(typed) == 0
|
|
case []any:
|
|
return len(typed) == 0
|
|
case string:
|
|
return typed == ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// flagToParamKey maps a CLI flag name to its JSON-RPC param key.
|
|
func flagToParamKey(key string) string {
|
|
switch key {
|
|
case "workspace":
|
|
return "workspace_id"
|
|
case "surface":
|
|
return "surface_id"
|
|
case "panel":
|
|
return "panel_id"
|
|
case "pane":
|
|
return "pane_id"
|
|
case "window":
|
|
return "window_id"
|
|
case "command":
|
|
return "initial_command"
|
|
case "name":
|
|
return "title"
|
|
case "working-directory":
|
|
return "working_directory"
|
|
default:
|
|
return key
|
|
}
|
|
}
|
|
|
|
// parsedFlags holds the results of flag parsing.
|
|
type parsedFlags struct {
|
|
flags map[string]string // --key value pairs
|
|
positional []string // non-flag arguments
|
|
}
|
|
|
|
// parseFlags extracts --key value pairs from args for the given allowed keys.
|
|
// Non-flag arguments are collected in positional.
|
|
func parseFlags(args []string, keys []string) (parsedFlags, error) {
|
|
allowed := make(map[string]bool, len(keys))
|
|
for _, k := range keys {
|
|
allowed[k] = true
|
|
}
|
|
|
|
result := parsedFlags{flags: make(map[string]string)}
|
|
for i := 0; i < len(args); i++ {
|
|
if args[i] == "--" {
|
|
result.positional = append(result.positional, args[i+1:]...)
|
|
break
|
|
}
|
|
if !strings.HasPrefix(args[i], "--") {
|
|
result.positional = append(result.positional, args[i])
|
|
continue
|
|
}
|
|
key := strings.TrimPrefix(args[i], "--")
|
|
if !allowed[key] {
|
|
return parsedFlags{}, fmt.Errorf("unknown flag --%s", key)
|
|
}
|
|
if i+1 < len(args) {
|
|
result.flags[key] = args[i+1]
|
|
i++
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback
|
|
// when CMUX_SOCKET_PATH is not set. Written by the cmux app after the relay establishes.
|
|
func readSocketAddrFile() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(home, ".cmux", "socket_addr"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func readRelayAuthFile(socketPath string) *relayAuthState {
|
|
if strings.Contains(socketPath, ":") && !strings.HasPrefix(socketPath, "/") {
|
|
_, port, err := net.SplitHostPort(socketPath)
|
|
if err != nil || port == "" {
|
|
return nil
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(home, ".cmux", "relay", port+".auth"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var state relayAuthState
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return nil
|
|
}
|
|
if state.RelayID == "" || state.RelayToken == "" {
|
|
return nil
|
|
}
|
|
return &state
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentRelayAuth(socketPath string) *relayAuthState {
|
|
relayID := strings.TrimSpace(os.Getenv("CMUX_RELAY_ID"))
|
|
relayToken := strings.TrimSpace(os.Getenv("CMUX_RELAY_TOKEN"))
|
|
if relayID != "" && relayToken != "" {
|
|
return &relayAuthState{RelayID: relayID, RelayToken: relayToken}
|
|
}
|
|
return readRelayAuthFile(socketPath)
|
|
}
|
|
|
|
// dialSocket connects to the cmux socket. If addr contains a colon and doesn't
|
|
// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket.
|
|
// For TCP connections, refreshAddr is used only to recover from a stale socket_addr
|
|
// rewrite, not to poll for relay readiness.
|
|
func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
|
|
if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") {
|
|
conn, connectedAddr, err := dialTCP(addr)
|
|
if err != nil && refreshAddr != nil && isConnectionRefused(err) {
|
|
if refreshedAddr := strings.TrimSpace(refreshAddr()); refreshedAddr != "" && refreshedAddr != addr {
|
|
addr = refreshedAddr
|
|
conn, connectedAddr, err = dialTCP(addr)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if auth := currentRelayAuth(connectedAddr); auth != nil {
|
|
if err := authenticateRelayConn(conn, auth); err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
return conn, nil
|
|
}
|
|
return net.Dial("unix", addr)
|
|
}
|
|
|
|
func dialTCP(addr string) (net.Conn, string, error) {
|
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err != nil {
|
|
return nil, addr, err
|
|
}
|
|
setTCPNoDelay(conn)
|
|
return conn, addr, nil
|
|
}
|
|
|
|
func isConnectionRefused(err error) bool {
|
|
if opErr, ok := err.(*net.OpError); ok {
|
|
return strings.Contains(opErr.Err.Error(), "connection refused")
|
|
}
|
|
return strings.Contains(err.Error(), "connection refused")
|
|
}
|
|
|
|
func authenticateRelayConn(conn net.Conn, auth *relayAuthState) error {
|
|
reader := bufio.NewReader(conn)
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
|
|
var challenge struct {
|
|
Protocol string `json:"protocol"`
|
|
Version int `json:"version"`
|
|
RelayID string `json:"relay_id"`
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read relay auth challenge: %w", err)
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &challenge); err != nil {
|
|
return fmt.Errorf("invalid relay auth challenge")
|
|
}
|
|
if challenge.Protocol != "cmux-relay-auth" || challenge.Version != 1 || challenge.RelayID != auth.RelayID || challenge.Nonce == "" {
|
|
return fmt.Errorf("relay auth challenge mismatch")
|
|
}
|
|
|
|
tokenBytes, err := hex.DecodeString(auth.RelayToken)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid relay auth token")
|
|
}
|
|
mac := computeRelayMAC(tokenBytes, auth.RelayID, challenge.Nonce, challenge.Version)
|
|
payload, err := json.Marshal(map[string]any{
|
|
"relay_id": auth.RelayID,
|
|
"mac": hex.EncodeToString(mac),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encode relay auth response: %w", err)
|
|
}
|
|
if _, err := conn.Write(append(payload, '\n')); err != nil {
|
|
return fmt.Errorf("failed to send relay auth response: %w", err)
|
|
}
|
|
|
|
line, err = reader.ReadString('\n')
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read relay auth result: %w", err)
|
|
}
|
|
var result struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &result); err != nil {
|
|
return fmt.Errorf("invalid relay auth result")
|
|
}
|
|
if !result.OK {
|
|
return fmt.Errorf("relay auth rejected")
|
|
}
|
|
_ = conn.SetDeadline(time.Time{})
|
|
return nil
|
|
}
|
|
|
|
func computeRelayMAC(token []byte, relayID, nonce string, version int) []byte {
|
|
mac := hmac.New(sha256.New, token)
|
|
_, _ = io.WriteString(mac, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, version))
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
// socketRoundTrip sends a raw text line and reads a raw text response (v1).
|
|
func socketRoundTrip(socketPath, command string, refreshAddr func() string) (string, error) {
|
|
conn, err := dialSocket(socketPath, refreshAddr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
if _, err := fmt.Fprintf(conn, "%s\n", command); err != nil {
|
|
return "", fmt.Errorf("failed to send command: %w", err)
|
|
}
|
|
|
|
// V1 handlers may return multiple lines (e.g. list_windows). Read until
|
|
// the stream goes idle briefly after seeing at least one newline.
|
|
reader := bufio.NewReader(conn)
|
|
var response strings.Builder
|
|
sawNewline := false
|
|
|
|
for {
|
|
readTimeout := 15 * time.Second
|
|
if sawNewline {
|
|
readTimeout = 120 * time.Millisecond
|
|
}
|
|
_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
|
|
|
|
chunk, err := reader.ReadString('\n')
|
|
if chunk != "" {
|
|
response.WriteString(chunk)
|
|
if strings.Contains(chunk, "\n") {
|
|
sawNewline = true
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
|
if sawNewline {
|
|
break
|
|
}
|
|
return "", fmt.Errorf("failed to read response: timeout waiting for response")
|
|
}
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return "", fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
}
|
|
|
|
return strings.TrimRight(response.String(), "\n"), nil
|
|
}
|
|
|
|
// socketRoundTripV2 sends a JSON-RPC request and returns the result JSON.
|
|
func socketRoundTripV2(socketPath, method string, params map[string]any, refreshAddr func() string) (string, error) {
|
|
conn, err := dialSocket(socketPath, refreshAddr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
id := randomHex(8)
|
|
req := map[string]any{
|
|
"id": id,
|
|
"method": method,
|
|
}
|
|
if params != nil {
|
|
req["params"] = params
|
|
} else {
|
|
req["params"] = map[string]any{}
|
|
}
|
|
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
if _, err := conn.Write(append(payload, '\n')); err != nil {
|
|
return "", fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
|
|
_ = conn.SetReadDeadline(time.Now().Add(15 * time.Second))
|
|
reader := bufio.NewReader(conn)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
// Parse the response to check for errors
|
|
var resp map[string]any
|
|
if err := json.Unmarshal([]byte(line), &resp); err != nil {
|
|
return strings.TrimRight(line, "\n"), nil
|
|
}
|
|
|
|
if ok, _ := resp["ok"].(bool); !ok {
|
|
if errObj, _ := resp["error"].(map[string]any); errObj != nil {
|
|
code, _ := errObj["code"].(string)
|
|
msg, _ := errObj["message"].(string)
|
|
return "", fmt.Errorf("server error [%s]: %s", code, msg)
|
|
}
|
|
return "", fmt.Errorf("server returned error response")
|
|
}
|
|
|
|
// Return the result portion as JSON
|
|
if result, ok := resp["result"]; ok {
|
|
resultJSON, err := json.Marshal(result)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal result: %w", err)
|
|
}
|
|
return string(resultJSON), nil
|
|
}
|
|
|
|
return "{}", nil
|
|
}
|
|
|
|
func randomHex(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func cliUsage() {
|
|
fmt.Fprintln(os.Stderr, "Usage: cmux [--socket <path>] [--json] <command> [args...]")
|
|
fmt.Fprintln(os.Stderr, "")
|
|
fmt.Fprintln(os.Stderr, "Commands:")
|
|
fmt.Fprintln(os.Stderr, " ping Check connectivity")
|
|
fmt.Fprintln(os.Stderr, " capabilities List server capabilities")
|
|
fmt.Fprintln(os.Stderr, " list-workspaces List all workspaces")
|
|
fmt.Fprintln(os.Stderr, " new-window Create a new window")
|
|
fmt.Fprintln(os.Stderr, " new-workspace Create a new workspace")
|
|
fmt.Fprintln(os.Stderr, " new-surface Create a new surface")
|
|
fmt.Fprintln(os.Stderr, " new-split Split an existing surface")
|
|
fmt.Fprintln(os.Stderr, " close-surface Close a surface")
|
|
fmt.Fprintln(os.Stderr, " close-workspace Close a workspace")
|
|
fmt.Fprintln(os.Stderr, " select-workspace Select a workspace")
|
|
fmt.Fprintln(os.Stderr, " send Send text to a surface")
|
|
fmt.Fprintln(os.Stderr, " send-key Send a key to a surface")
|
|
fmt.Fprintln(os.Stderr, " notify Create a notification")
|
|
fmt.Fprintln(os.Stderr, " browser <sub> Browser commands (open, navigate, back, forward, reload, get-url)")
|
|
fmt.Fprintln(os.Stderr, " claude-teams [args...] Launch Claude Code in teammate mode")
|
|
fmt.Fprintln(os.Stderr, " omo [args...] Launch OpenCode with cmux integration")
|
|
fmt.Fprintln(os.Stderr, " rpc <method> [json-params] Send arbitrary JSON-RPC")
|
|
}
|