From 42d3be44cdfea654ade56450e8ff1232eac0f6bd Mon Sep 17 00:00:00 2001 From: Ronen Kalo Date: Mon, 16 Mar 2026 23:44:44 -0400 Subject: [PATCH 1/2] feat: add `oclaw pair` command for mobile app QR pairing Generates a QR code containing gateway connection details (LAN IP, port, token) that the upcoming oclaw mobile app can scan to connect instantly. - Detects LAN IP with preference for physical interfaces (en*/eth*) - Supports --host flag to override auto-detection - Masks token in terminal output for security - Uses PersistentFlags so subcommands inherit --url/--token/--agent --- cmd/pair.go | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 6 +-- go.mod | 1 + go.sum | 2 + 4 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 cmd/pair.go diff --git a/cmd/pair.go b/cmd/pair.go new file mode 100644 index 0000000..3c82533 --- /dev/null +++ b/cmd/pair.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "fmt" + "net" + "net/url" + + qrcode "github.com/skip2/go-qrcode" + "github.com/spf13/cobra" + + "github.com/quantum-bytes/oclaw/internal/config" +) + +var pairHost string + +var pairCmd = &cobra.Command{ + Use: "pair", + Short: "Display a QR code for pairing the oclaw mobile app", + Long: `Generates a QR code containing the gateway connection details. +Scan this code with the oclaw mobile app to connect instantly. + +The QR encodes an oclaw:// URI with the gateway's LAN IP, port, and auth token. +Use --host to override the auto-detected LAN IP if the wrong interface is selected.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(flagURL, flagToken, flagAgent) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + // Parse gateway URL to extract port + gwURL, err := url.Parse(cfg.GatewayURL) + if err != nil { + return fmt.Errorf("parse gateway URL %q: %w", cfg.GatewayURL, err) + } + + port := gwURL.Port() + if port == "" { + port = "39421" + } + + // Determine host: explicit flag > auto-detect + host := pairHost + if host == "" { + host, err = detectLANIP() + if err != nil { + return fmt.Errorf("detect LAN IP (use --host to specify manually): %w", err) + } + } + + // Build oclaw:// URI using url.URL for correctness + pairURI := &url.URL{ + Scheme: "oclaw", + Host: net.JoinHostPort(host, port), + RawQuery: url.Values{"token": {cfg.Token}}.Encode(), + } + uri := pairURI.String() + + // Generate QR code as terminal string + qr, err := qrcode.New(uri, qrcode.Medium) + if err != nil { + return fmt.Errorf("generate QR code: %w", err) + } + + // Mask token for display + maskedToken := cfg.Token + if len(maskedToken) > 4 { + maskedToken = maskedToken[:4] + "****" + } + + fmt.Println() + fmt.Println(" Scan this QR code with the oclaw mobile app:") + fmt.Println() + fmt.Print(qr.ToSmallString(false)) + fmt.Println() + fmt.Printf(" Host: %s:%s\n", host, port) + fmt.Printf(" Token: %s\n\n", maskedToken) + + return nil + }, +} + +func init() { + pairCmd.Flags().StringVar(&pairHost, "host", "", "Override auto-detected LAN IP (e.g., 192.168.1.100)") + rootCmd.AddCommand(pairCmd) +} + +// detectLANIP returns the first non-loopback, non-virtual IPv4 address. +// It prefers physical interfaces (en*, eth*) over virtual ones (docker*, veth*, br*, utun*). +func detectLANIP() (string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return "", err + } + + var fallback string + + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + + if ip == nil || ip.IsLoopback() { + continue + } + + ip4 := ip.To4() + if ip4 == nil { + continue + } + + // Skip link-local (169.254.x.x) + if ip4[0] == 169 && ip4[1] == 254 { + continue + } + + // Prefer physical interfaces (en0, en1, eth0, etc.) + name := iface.Name + if (len(name) >= 2 && name[:2] == "en") || (len(name) >= 3 && name[:3] == "eth") { + return ip4.String(), nil + } + + // Store first non-physical as fallback + if fallback == "" { + fallback = ip4.String() + } + } + } + + if fallback != "" { + return fallback, nil + } + + return "", fmt.Errorf("no LAN IPv4 address found") +} diff --git a/cmd/root.go b/cmd/root.go index 6e30fee..4ef0322 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,9 +47,9 @@ bypassing the buggy built-in TUI.`, } func init() { - rootCmd.Flags().StringVar(&flagURL, "url", "", "Gateway WebSocket URL (default: from config or ws://127.0.0.1:39421)") - rootCmd.Flags().StringVar(&flagToken, "token", "", "Gateway auth token (default: from config)") - rootCmd.Flags().StringVar(&flagAgent, "agent", "", "Default agent ID to connect to") + rootCmd.PersistentFlags().StringVar(&flagURL, "url", "", "Gateway WebSocket URL (default: from config or ws://127.0.0.1:39421)") + rootCmd.PersistentFlags().StringVar(&flagToken, "token", "", "Gateway auth token (default: from config)") + rootCmd.PersistentFlags().StringVar(&flagAgent, "agent", "", "Default agent ID to connect to") } func Execute() { diff --git a/go.mod b/go.mod index 8df3548..95c0090 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect diff --git a/go.sum b/go.sum index 48b08c2..cabdc0d 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= From c03f85b5802b2d819a160ba5e29078d25c05b257 Mon Sep 17 00:00:00 2001 From: Ronen Kalo Date: Tue, 17 Mar 2026 01:00:02 -0400 Subject: [PATCH 2/2] feat: include device credentials in pair QR for scope authorization The gateway requires Ed25519 device signing to grant operator scopes. The pair command now reads ~/.openclaw/identity/device.json and includes the device ID and private key seed in the QR URI so the mobile app can authenticate with full permissions. --- cmd/pair.go | 86 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/cmd/pair.go b/cmd/pair.go index 3c82533..220056d 100644 --- a/cmd/pair.go +++ b/cmd/pair.go @@ -1,9 +1,16 @@ package cmd import ( + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" "fmt" "net" "net/url" + "os" + "path/filepath" qrcode "github.com/skip2/go-qrcode" "github.com/spf13/cobra" @@ -19,7 +26,8 @@ var pairCmd = &cobra.Command{ Long: `Generates a QR code containing the gateway connection details. Scan this code with the oclaw mobile app to connect instantly. -The QR encodes an oclaw:// URI with the gateway's LAN IP, port, and auth token. +The QR encodes an oclaw:// URI with the gateway's LAN IP, port, auth token, +and device credentials for scope authorization. Use --host to override the auto-detected LAN IP if the wrong interface is selected.`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load(flagURL, flagToken, flagAgent) @@ -47,11 +55,24 @@ Use --host to override the auto-detected LAN IP if the wrong interface is select } } - // Build oclaw:// URI using url.URL for correctness + // Build query params + params := url.Values{"token": {cfg.Token}} + + // Load device identity for scope authorization + deviceID, privKeyB64, err := loadDeviceKey() + if err != nil { + fmt.Fprintf(os.Stderr, " Warning: no device identity found (%v)\n", err) + fmt.Fprintf(os.Stderr, " Mobile app will connect without operator scopes\n\n") + } else { + params.Set("did", deviceID) + params.Set("dkey", privKeyB64) + } + + // Build oclaw:// URI pairURI := &url.URL{ Scheme: "oclaw", Host: net.JoinHostPort(host, port), - RawQuery: url.Values{"token": {cfg.Token}}.Encode(), + RawQuery: params.Encode(), } uri := pairURI.String() @@ -72,8 +93,12 @@ Use --host to override the auto-detected LAN IP if the wrong interface is select fmt.Println() fmt.Print(qr.ToSmallString(false)) fmt.Println() - fmt.Printf(" Host: %s:%s\n", host, port) - fmt.Printf(" Token: %s\n\n", maskedToken) + fmt.Printf(" Host: %s:%s\n", host, port) + fmt.Printf(" Token: %s\n", maskedToken) + if deviceID != "" { + fmt.Printf(" Device: %s...%s\n", deviceID[:8], deviceID[len(deviceID)-8:]) + } + fmt.Println() return nil }, @@ -84,8 +109,54 @@ func init() { rootCmd.AddCommand(pairCmd) } +// deviceJSON is the structure of ~/.openclaw/identity/device.json. +type deviceJSON struct { + DeviceID string `json:"deviceId"` + PrivateKeyPem string `json:"privateKeyPem"` +} + +// loadDeviceKey reads the device identity and returns the device ID and +// base64-encoded raw Ed25519 private key seed (32 bytes). +func loadDeviceKey() (deviceID, privKeyB64 string, err error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + + path := filepath.Join(home, ".openclaw", "identity", "device.json") + data, err := os.ReadFile(path) + if err != nil { + return "", "", err + } + + var dev deviceJSON + if err := json.Unmarshal(data, &dev); err != nil { + return "", "", fmt.Errorf("parse device.json: %w", err) + } + + // Parse PEM-encoded private key + block, _ := pem.Decode([]byte(dev.PrivateKeyPem)) + if block == nil { + return "", "", fmt.Errorf("no PEM block found in privateKeyPem") + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", "", fmt.Errorf("parse PKCS8 key: %w", err) + } + + edKey, ok := key.(ed25519.PrivateKey) + if !ok { + return "", "", fmt.Errorf("not an Ed25519 key") + } + + // Ed25519 private key is 64 bytes; seed is first 32 + seed := edKey.Seed() + return dev.DeviceID, base64.StdEncoding.EncodeToString(seed), nil +} + // detectLANIP returns the first non-loopback, non-virtual IPv4 address. -// It prefers physical interfaces (en*, eth*) over virtual ones (docker*, veth*, br*, utun*). +// It prefers physical interfaces (en*, eth*) over virtual ones. func detectLANIP() (string, error) { ifaces, err := net.Interfaces() if err != nil { @@ -122,18 +193,15 @@ func detectLANIP() (string, error) { continue } - // Skip link-local (169.254.x.x) if ip4[0] == 169 && ip4[1] == 254 { continue } - // Prefer physical interfaces (en0, en1, eth0, etc.) name := iface.Name if (len(name) >= 2 && name[:2] == "en") || (len(name) >= 3 && name[:3] == "eth") { return ip4.String(), nil } - // Store first non-physical as fallback if fallback == "" { fallback = ip4.String() }