From c00b5ada10aadd17bb6bd098138d45524e0e7c68 Mon Sep 17 00:00:00 2001 From: Chris Scelza Date: Thu, 8 Oct 2026 11:24:02 -0400 Subject: [PATCH 1/4] feat(ios): add MOBILECLI_IOS_AGENT_URL to use an already running agent When set, a real iOS device uses the DeviceKit agent at that URL after a health check, without listing apps, starting the go-ios tunnel, setting up a port forward, or launching the runner. Combined with DEVICEKIT_LISTEN_HOST (devicekit-ios#92) this drives a Wi-Fi paired iPhone through the Xcode CoreDevice tunnel address. The MJPEG URL now follows the agent base URL. Refs https://github.com/mobile-next/mobile-mcp/issues/483 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 (orchestration) --- README.md | 15 +++++ devices/common.go | 7 ++- devices/devicekit/types.go | 5 ++ devices/ios.go | 11 +++- devices/ios_agent_url.go | 50 ++++++++++++++++ devices/ios_agent_url_test.go | 105 ++++++++++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 devices/ios_agent_url.go create mode 100644 devices/ios_agent_url_test.go diff --git a/README.md b/README.md index cfa1a31e..739c3f17 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,21 @@ The CLI and daemon talk JSON-RPC over a unix domain socket in `~/.mobilecli/` (o On Android, the daemon keeps a small server running on the device (`com.mobilenext.mobilecli.DeviceServer`) that holds the device's only UiAutomation connection, so other UiAutomator clients such as `uiautomator dump` or Appium cannot run alongside it. The daemon stops that server when it exits, so `mobilecli daemon stop` hands the device back to other tools; the server also exits by itself after 30 minutes without requests (`MOBILECLI_DEVICE_SERVER_IDLE_TIMEOUT=5m` shortens that). +On a real iOS device, the daemon launches the on-device agent itself and reaches it through a tunnel (iOS 17+) and a port forward. If the agent is already running and reachable another way, set `MOBILECLI_IOS_AGENT_URL` to its URL and the daemon uses that agent as is, without launching it or starting a tunnel or forward for it. The URL applies to every real iOS device, and commands that do not go through the agent (apps, logs, crashes, AVC streaming) are unchanged. The daemon reads the variable when it starts, so run `mobilecli daemon stop` after changing it. + +For a device paired over Wi-Fi, the agent can listen on the Xcode CoreDevice tunnel address (devicekit-ios `DEVICEKIT_LISTEN_HOST`), which only the paired Mac can reach: + +```bash +UDID= +TUNNEL=$(xcrun devicectl device info details --device $UDID --json-output - | jq -r '.result.connectionProperties.tunnelIPAddress') +# in the devicekit-ios checkout, after building the runner for the device once +TEST_RUNNER_DEVICEKIT_LISTEN_HOST="127.0.0.1,$TUNNEL" xcodebuild test-without-building \ + -project devicekit-ios.xcodeproj -scheme devicekit-ios -destination "id=$UDID" -collect-test-diagnostics never & +MOBILECLI_IOS_AGENT_URL="http://[$TUNNEL]:12004" mobilecli --device $UDID device info +``` + +The tunnel address can change when the tunnel reconnects, so read it right before launching the runner. With USB, the same variable works with a forward instead, for example `iproxy 8100 12004` and `MOBILECLI_IOS_AGENT_URL=http://localhost:8100`. + ## HTTP API 🔌 ***mobilecli*** provides an http interface for all the functionality that is available through command line. The server is a thin front for the daemon above, so HTTP clients and the CLI share one set of devices and tunnels. diff --git a/devices/common.go b/devices/common.go index b59026d4..d9eb502e 100644 --- a/devices/common.go +++ b/devices/common.go @@ -82,7 +82,12 @@ const ( ) func buildMjpegURL(port, fps int, scale float64) string { - url := fmt.Sprintf("http://localhost:%d/mjpeg", port) + return buildMjpegURLForAgent(fmt.Sprintf("http://localhost:%d", port), fps, scale) +} + +// buildMjpegURLForAgent builds the mjpeg URL served by the agent at baseURL. +func buildMjpegURLForAgent(baseURL string, fps int, scale float64) string { + url := baseURL + "/mjpeg" sep := "?" if fps > 0 { url += fmt.Sprintf("%sfps=%d", sep, fps) diff --git a/devices/devicekit/types.go b/devices/devicekit/types.go index c10fba89..9d4cdfc9 100644 --- a/devices/devicekit/types.go +++ b/devices/devicekit/types.go @@ -31,6 +31,11 @@ func NewDeviceKitClient(hostPort string) *DeviceKitClient { } } +// BaseURL returns the agent URL this client talks to, without a trailing slash. +func (c *DeviceKitClient) BaseURL() string { + return c.baseURL +} + // Port returns the port this client talks to, parsed from its base URL. func (c *DeviceKitClient) Port() int { parsed, err := url.Parse(c.baseURL) diff --git a/devices/ios.go b/devices/ios.go index 4b88eae2..37eb9faf 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -500,6 +500,14 @@ func (d *IOSDevice) StartAgent(config StartAgentConfig) error { // 6. we need to wait for the agent to be ready ✅ // 7. just in case, click HOME button ✅ + agentURL, err := iosAgentURLOverride() + if err != nil { + return err + } + if agentURL != "" { + return d.useExternalAgent(agentURL) + } + if _, err := d.deviceKitClient.GetStatus(); err == nil { return nil } @@ -1149,8 +1157,7 @@ func (d *IOSDevice) StartScreenCapture(config ScreenCaptureConfig) error { // mjpeg is served on the same port as the agent HTTP server at /mjpeg d.mu.Lock() - wdaPort := d.deviceKitClient.Port() - mjpegURL := buildMjpegURL(wdaPort, config.FPS, config.Scale) + mjpegURL := buildMjpegURLForAgent(d.deviceKitClient.BaseURL(), config.FPS, config.Scale) d.mjpegClient = mjpeg.NewDeviceKitMjpegClient(mjpegURL) d.mu.Unlock() diff --git a/devices/ios_agent_url.go b/devices/ios_agent_url.go new file mode 100644 index 00000000..403b903c --- /dev/null +++ b/devices/ios_agent_url.go @@ -0,0 +1,50 @@ +package devices + +import ( + "fmt" + "net/url" + "os" + "strings" + + "github.com/mobile-next/mobilecli/devices/devicekit" +) + +// iosAgentURLEnv points real iOS devices at a DeviceKit agent that is already +// running and reachable at that URL (e.g. "http://localhost:8100", or the Xcode +// CoreDevice tunnel address of a Wi-Fi paired device such as +// "http://[fd34:13d9:5468::1]:12004"). When set, +// mobilecli neither launches the agent nor starts a tunnel or port forward to +// reach it. Calls that do not go through the agent (apps, logs, crashes) are +// unchanged. +const iosAgentURLEnv = "MOBILECLI_IOS_AGENT_URL" + +// iosAgentURLOverride returns the agent URL from iosAgentURLEnv, or "" when it +// is not set. +func iosAgentURLOverride() (string, error) { + value := strings.TrimSpace(os.Getenv(iosAgentURLEnv)) + if value == "" { + return "", nil + } + + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", fmt.Errorf("%s=%q is not a valid agent URL, expected something like http://localhost:8100", iosAgentURLEnv, value) + } + + return strings.TrimSuffix(value, "/"), nil +} + +// useExternalAgent switches the device to the agent at agentURL and checks that +// it answers. It never launches the agent or sets up a tunnel or forward. +func (d *IOSDevice) useExternalAgent(agentURL string) error { + client := devicekit.NewDeviceKitClient(agentURL) + if _, err := client.GetStatus(); err != nil { + return fmt.Errorf("no agent answering at %s (from %s): %w", agentURL, iosAgentURLEnv, err) + } + + d.mu.Lock() + d.deviceKitClient = client + d.mu.Unlock() + + return nil +} diff --git a/devices/ios_agent_url_test.go b/devices/ios_agent_url_test.go new file mode 100644 index 00000000..64bc5e71 --- /dev/null +++ b/devices/ios_agent_url_test.go @@ -0,0 +1,105 @@ +package devices + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mobile-next/mobilecli/devices/devicekit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeAgentHandler answers /health and device.info the way devicekit-ios does. +func fakeAgentHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/rpc", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"scale":3,"screenSize":{"width":393,"height":852}}}`)) + }) + return mux +} + +func fakeAgent(t *testing.T) *httptest.Server { + agent := httptest.NewServer(fakeAgentHandler()) + t.Cleanup(agent.Close) + return agent +} + +// an iOS 17 device with no tunnel manager: any attempt to start a tunnel, +// forward a port or list apps would fail instead of reaching the fake agent. +func deviceWithoutTunnel(agentURL string) *IOSDevice { + return &IOSDevice{ + Udid: "00008110-000000000000001E", + OSVersion: "17.5", + deviceKitClient: devicekit.NewDeviceKitClient(agentURL), + } +} + +func TestStartAgentUsesTheAgentURLFromTheEnvironment(t *testing.T) { + agent := fakeAgent(t) + t.Setenv(iosAgentURLEnv, agent.URL+"/") + + // the default client points somewhere nothing listens + device := deviceWithoutTunnel("http://127.0.0.1:1") + + require.NoError(t, device.StartAgent(StartAgentConfig{})) + assert.Equal(t, agent.URL, device.deviceKitClient.BaseURL()) + + info, err := device.Info() + require.NoError(t, err) + assert.Equal(t, 393, info.ScreenSize.Width) + assert.Equal(t, 852, info.ScreenSize.Height) +} + +// the Xcode CoreDevice tunnel address is IPv6, so the URL carries a bracketed literal +func TestStartAgentAcceptsABracketedIPv6AgentURL(t *testing.T) { + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp6", "[::1]:0") + if err != nil { + t.Skipf("no IPv6 loopback: %v", err) + } + agent := httptest.NewUnstartedServer(fakeAgentHandler()) + agent.Listener = listener + agent.Start() + t.Cleanup(agent.Close) + require.Contains(t, agent.URL, "[::1]") + t.Setenv(iosAgentURLEnv, agent.URL) + + device := deviceWithoutTunnel("http://127.0.0.1:1") + + require.NoError(t, device.StartAgent(StartAgentConfig{})) + info, err := device.Info() + require.NoError(t, err) + assert.Equal(t, 393, info.ScreenSize.Width) +} + +func TestStartAgentReportsAnAgentURLNothingAnswers(t *testing.T) { + t.Setenv(iosAgentURLEnv, "http://127.0.0.1:1") + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no agent answering at http://127.0.0.1:1") + assert.Contains(t, err.Error(), iosAgentURLEnv) +} + +func TestStartAgentRejectsAnInvalidAgentURL(t *testing.T) { + for _, value := range []string{"localhost:8100", "ftp://localhost:8100", "http://"} { + t.Setenv(iosAgentURLEnv, value) + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err, value) + assert.Contains(t, err.Error(), "is not a valid agent URL", value) + } +} + +func TestMjpegURLFollowsTheAgentURL(t *testing.T) { + assert.Equal(t, "http://192.168.1.20:8100/mjpeg?fps=10", buildMjpegURLForAgent("http://192.168.1.20:8100", 10, 1)) + assert.Equal(t, "http://[fd34:13d9:5468::1]:12004/mjpeg?fps=10", buildMjpegURLForAgent("http://[fd34:13d9:5468::1]:12004", 10, 1)) + assert.Equal(t, "http://localhost:8100/mjpeg?scale=50", buildMjpegURL(8100, 0, 0.5)) +} From 8e8ced900261e11e11c5a5507a136b1cecf7fa8d Mon Sep 17 00:00:00 2001 From: Chris Scelza Date: Thu, 8 Oct 2026 11:42:07 -0400 Subject: [PATCH 2/4] fix(ios): scope MOBILECLI_IOS_AGENT_URL per device, reject unsafe URLs Accept = pairs so each device uses only its own agent; a bare URL is used only while a single real iOS device is connected, since the agent cannot report which device it runs on. Reject agent URLs with a query, a fragment, or user info. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 (orchestration) --- README.md | 6 +- devices/ios.go | 2 +- devices/ios_agent_url.go | 105 ++++++++++++++++++++++++++++-- devices/ios_agent_url_test.go | 118 +++++++++++++++++++++++++++++++++- 4 files changed, 220 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 739c3f17..9910ad70 100644 --- a/README.md +++ b/README.md @@ -513,7 +513,9 @@ The CLI and daemon talk JSON-RPC over a unix domain socket in `~/.mobilecli/` (o On Android, the daemon keeps a small server running on the device (`com.mobilenext.mobilecli.DeviceServer`) that holds the device's only UiAutomation connection, so other UiAutomator clients such as `uiautomator dump` or Appium cannot run alongside it. The daemon stops that server when it exits, so `mobilecli daemon stop` hands the device back to other tools; the server also exits by itself after 30 minutes without requests (`MOBILECLI_DEVICE_SERVER_IDLE_TIMEOUT=5m` shortens that). -On a real iOS device, the daemon launches the on-device agent itself and reaches it through a tunnel (iOS 17+) and a port forward. If the agent is already running and reachable another way, set `MOBILECLI_IOS_AGENT_URL` to its URL and the daemon uses that agent as is, without launching it or starting a tunnel or forward for it. The URL applies to every real iOS device, and commands that do not go through the agent (apps, logs, crashes, AVC streaming) are unchanged. The daemon reads the variable when it starts, so run `mobilecli daemon stop` after changing it. +On a real iOS device, the daemon launches the on-device agent itself and reaches it through a tunnel (iOS 17+) and a port forward. If the agent is already running and reachable another way, set `MOBILECLI_IOS_AGENT_URL` to its URL and the daemon uses that agent as is, without launching it or starting a tunnel or forward for it. Commands that do not go through the agent (apps, logs, crashes, AVC streaming) are unchanged. + +The variable takes either a bare URL, which mobilecli accepts only while exactly one real iOS device is connected, or a comma-separated list of `=` pairs such as `00008101-00161CEC3CDB001E=http://[fd34:13d9:5468::1]:12004,00008110-001A2B3C4D5E001E=http://localhost:8100`. With a list, each device uses only its own entry and a device without one is reached the usual way. The agent cannot report which device it runs on, so the mapping is what binds a device to its agent. The URL cannot have a query, a fragment, or a user name and password. The daemon reads the variable when it starts, so run `mobilecli daemon stop` after changing it. For a device paired over Wi-Fi, the agent can listen on the Xcode CoreDevice tunnel address (devicekit-ios `DEVICEKIT_LISTEN_HOST`), which only the paired Mac can reach: @@ -523,7 +525,7 @@ TUNNEL=$(xcrun devicectl device info details --device $UDID --json-output - | jq # in the devicekit-ios checkout, after building the runner for the device once TEST_RUNNER_DEVICEKIT_LISTEN_HOST="127.0.0.1,$TUNNEL" xcodebuild test-without-building \ -project devicekit-ios.xcodeproj -scheme devicekit-ios -destination "id=$UDID" -collect-test-diagnostics never & -MOBILECLI_IOS_AGENT_URL="http://[$TUNNEL]:12004" mobilecli --device $UDID device info +MOBILECLI_IOS_AGENT_URL="$UDID=http://[$TUNNEL]:12004" mobilecli --device $UDID device info ``` The tunnel address can change when the tunnel reconnects, so read it right before launching the runner. With USB, the same variable works with a forward instead, for example `iproxy 8100 12004` and `MOBILECLI_IOS_AGENT_URL=http://localhost:8100`. diff --git a/devices/ios.go b/devices/ios.go index 37eb9faf..f4b26c5e 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -500,7 +500,7 @@ func (d *IOSDevice) StartAgent(config StartAgentConfig) error { // 6. we need to wait for the agent to be ready ✅ // 7. just in case, click HOME button ✅ - agentURL, err := iosAgentURLOverride() + agentURL, err := iosAgentURLOverride(d.Udid) if err != nil { return err } diff --git a/devices/ios_agent_url.go b/devices/ios_agent_url.go index 403b903c..fc7b258e 100644 --- a/devices/ios_agent_url.go +++ b/devices/ios_agent_url.go @@ -6,30 +6,121 @@ import ( "os" "strings" + goios "github.com/danielpaulus/go-ios/ios" "github.com/mobile-next/mobilecli/devices/devicekit" ) // iosAgentURLEnv points real iOS devices at a DeviceKit agent that is already // running and reachable at that URL (e.g. "http://localhost:8100", or the Xcode // CoreDevice tunnel address of a Wi-Fi paired device such as -// "http://[fd34:13d9:5468::1]:12004"). When set, -// mobilecli neither launches the agent nor starts a tunnel or port forward to -// reach it. Calls that do not go through the agent (apps, logs, crashes) are -// unchanged. +// "http://[fd34:13d9:5468::1]:12004"). When set, mobilecli neither launches the +// agent nor starts a tunnel or port forward to reach it. Calls that do not go +// through the agent (apps, logs, crashes) are unchanged. +// +// The value is either a comma-separated list of = pairs, where each +// device uses only its own entry and a device without one takes the normal +// tunnel path, or a bare URL, which is accepted only while exactly one real iOS +// device is connected. The agent cannot confirm which device it runs on +// (device.info returns only the screen size and scale), so the device is bound +// to the agent by this mapping alone. const iosAgentURLEnv = "MOBILECLI_IOS_AGENT_URL" -// iosAgentURLOverride returns the agent URL from iosAgentURLEnv, or "" when it -// is not set. -func iosAgentURLOverride() (string, error) { +// countConnectedIOSDevices returns how many distinct real iOS devices usbmuxd +// reports. It only asks usbmuxd for its list, without opening a lockdown +// session per device as ListIOSDevices does. Tests replace it. +var countConnectedIOSDevices = func() (int, error) { + deviceList, err := goios.ListDevices() + if err != nil { + return 0, fmt.Errorf("failed getting device list: %w", err) + } + + // go-ios returns one entry per connection (usb, network), count by udid + seen := make(map[string]bool) + for _, deviceEntry := range deviceList.DeviceList { + seen[deviceEntry.Properties.SerialNumber] = true + } + return len(seen), nil +} + +// iosAgentURLOverride returns the agent URL that iosAgentURLEnv sets for the +// device with this udid, or "" when the device should take the normal path. +func iosAgentURLOverride(udid string) (string, error) { value := strings.TrimSpace(os.Getenv(iosAgentURLEnv)) if value == "" { return "", nil } + // agent URLs cannot carry a query, so '=' and ',' only appear in a mapping + if !strings.ContainsAny(value, "=,") { + return bareAgentURL(value) + } + + agentURLs, err := parseAgentURLMapping(value) + if err != nil { + return "", err + } + return agentURLs[strings.ToUpper(udid)], nil +} + +// bareAgentURL accepts a URL without a udid only when it cannot reach the +// wrong device, that is with a single real iOS device connected. +func bareAgentURL(value string) (string, error) { + agentURL, err := parseAgentURL(value) + if err != nil { + return "", err + } + + count, err := countConnectedIOSDevices() + if err != nil { + return "", err + } + if count > 1 { + return "", fmt.Errorf("%s holds a single URL but %d iOS devices are connected, use = to say which device runs the agent", iosAgentURLEnv, count) + } + + return agentURL, nil +} + +// parseAgentURLMapping parses "=,=" into URLs keyed by +// upper-case udid. +func parseAgentURLMapping(value string) (map[string]string, error) { + agentURLs := make(map[string]string) + for _, entry := range strings.Split(value, ",") { + udid, rawURL, found := strings.Cut(strings.TrimSpace(entry), "=") + udid = strings.TrimSpace(udid) + rawURL = strings.TrimSpace(rawURL) + if !found || udid == "" || rawURL == "" || strings.ContainsAny(udid, ":/") { + return nil, fmt.Errorf("%s has a malformed entry %q, expected = such as 00008101-00161CEC3CDB001E=http://localhost:8100 (agent URLs cannot have a query)", iosAgentURLEnv, entry) + } + + key := strings.ToUpper(udid) + if _, duplicate := agentURLs[key]; duplicate { + return nil, fmt.Errorf("%s lists device %s more than once", iosAgentURLEnv, udid) + } + + agentURL, err := parseAgentURL(rawURL) + if err != nil { + return nil, err + } + agentURLs[key] = agentURL + } + return agentURLs, nil +} + +// parseAgentURL checks that value can serve as the base of agent requests, +// which append /health, /rpc and /mjpeg to it. +func parseAgentURL(value string) (string, error) { parsed, err := url.Parse(value) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { return "", fmt.Errorf("%s=%q is not a valid agent URL, expected something like http://localhost:8100", iosAgentURLEnv, value) } + if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(value, "#") { + return "", fmt.Errorf("%s=%q is not a valid agent URL, it cannot have a query or fragment", iosAgentURLEnv, value) + } + // the agent has no authentication, so credentials would only travel in the clear + if parsed.User != nil { + return "", fmt.Errorf("%s=%q is not a valid agent URL, it cannot carry a user name or password", iosAgentURLEnv, value) + } return strings.TrimSuffix(value, "/"), nil } diff --git a/devices/ios_agent_url_test.go b/devices/ios_agent_url_test.go index 64bc5e71..e53e0cd8 100644 --- a/devices/ios_agent_url_test.go +++ b/devices/ios_agent_url_test.go @@ -33,14 +33,27 @@ func fakeAgent(t *testing.T) *httptest.Server { // an iOS 17 device with no tunnel manager: any attempt to start a tunnel, // forward a port or list apps would fail instead of reaching the fake agent. func deviceWithoutTunnel(agentURL string) *IOSDevice { + return deviceWithUdid("00008110-000000000000001E", agentURL) +} + +func deviceWithUdid(udid, agentURL string) *IOSDevice { return &IOSDevice{ - Udid: "00008110-000000000000001E", + Udid: udid, OSVersion: "17.5", deviceKitClient: devicekit.NewDeviceKitClient(agentURL), } } +// withConnectedIOSDevices makes the bare URL check see count devices instead of +// asking usbmuxd. +func withConnectedIOSDevices(t *testing.T, count int) { + original := countConnectedIOSDevices + countConnectedIOSDevices = func() (int, error) { return count, nil } + t.Cleanup(func() { countConnectedIOSDevices = original }) +} + func TestStartAgentUsesTheAgentURLFromTheEnvironment(t *testing.T) { + withConnectedIOSDevices(t, 1) agent := fakeAgent(t) t.Setenv(iosAgentURLEnv, agent.URL+"/") @@ -67,6 +80,7 @@ func TestStartAgentAcceptsABracketedIPv6AgentURL(t *testing.T) { agent.Start() t.Cleanup(agent.Close) require.Contains(t, agent.URL, "[::1]") + withConnectedIOSDevices(t, 1) t.Setenv(iosAgentURLEnv, agent.URL) device := deviceWithoutTunnel("http://127.0.0.1:1") @@ -78,6 +92,7 @@ func TestStartAgentAcceptsABracketedIPv6AgentURL(t *testing.T) { } func TestStartAgentReportsAnAgentURLNothingAnswers(t *testing.T) { + withConnectedIOSDevices(t, 1) t.Setenv(iosAgentURLEnv, "http://127.0.0.1:1") err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) @@ -88,6 +103,7 @@ func TestStartAgentReportsAnAgentURLNothingAnswers(t *testing.T) { } func TestStartAgentRejectsAnInvalidAgentURL(t *testing.T) { + withConnectedIOSDevices(t, 1) for _, value := range []string{"localhost:8100", "ftp://localhost:8100", "http://"} { t.Setenv(iosAgentURLEnv, value) @@ -98,6 +114,106 @@ func TestStartAgentRejectsAnInvalidAgentURL(t *testing.T) { } } +// /health and /rpc are appended to the agent URL, so a query or fragment would +// swallow them, and the agent has no authentication to send credentials to. +func TestStartAgentRejectsAQueryFragmentOrUserInfo(t *testing.T) { + withConnectedIOSDevices(t, 1) + cases := map[string]string{ + "http://localhost:8100?x": "cannot have a query or fragment", + "http://localhost:8100/?": "cannot have a query or fragment", + "http://localhost:8100/#": "cannot have a query or fragment", + "http://localhost:8100/#health": "cannot have a query or fragment", + "http://user:secret@192.168.1.20:8100": "cannot carry a user name or password", + "https://user@localhost:8100": "cannot carry a user name or password", + "00008110-000000000000001E=http://u:p@h:81": "cannot carry a user name or password", + "00008110-000000000000001E=http://h:81/#x": "cannot have a query or fragment", + } + for value, message := range cases { + t.Setenv(iosAgentURLEnv, value) + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err, value) + assert.Contains(t, err.Error(), message, value) + assert.Contains(t, err.Error(), iosAgentURLEnv, value) + } +} + +// a bare URL with a query looks like a mapping and is reported as malformed +func TestStartAgentRejectsMalformedMappings(t *testing.T) { + for _, value := range []string{ + "http://localhost:8100?a=b", + "00008110-000000000000001E=", + "=http://localhost:8100", + "00008110-000000000000001E=http://localhost:8100,", + "00008110-000000000000001E http://localhost:8100,00008101-00161CEC3CDB001E=http://localhost:8101", + } { + t.Setenv(iosAgentURLEnv, value) + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err, value) + assert.Contains(t, err.Error(), "expected =", value) + assert.Contains(t, err.Error(), iosAgentURLEnv, value) + } +} + +func TestStartAgentRejectsADeviceListedTwice(t *testing.T) { + t.Setenv(iosAgentURLEnv, "00008110-000000000000001E=http://localhost:8100,00008110-000000000000001e=http://localhost:8101") + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "more than once") +} + +func TestStartAgentPicksTheDevicesOwnEntry(t *testing.T) { + // the mapping is used as is, without asking usbmuxd how many devices there are + withConnectedIOSDevices(t, 5) + mine := fakeAgent(t) + other := fakeAgent(t) + t.Setenv(iosAgentURLEnv, "00008101-00161CEC3CDB001E="+other.URL+", 00008110-000000000000001e = "+mine.URL+"/") + + device := deviceWithoutTunnel("http://127.0.0.1:1") + + require.NoError(t, device.StartAgent(StartAgentConfig{})) + assert.Equal(t, mine.URL, device.deviceKitClient.BaseURL()) +} + +func TestStartAgentAcceptsAnIPv6AgentURLInAMapping(t *testing.T) { + t.Setenv(iosAgentURLEnv, "00008101-00161CEC3CDB001E=http://[fd34:13d9:5468::1]:12004,00008110-000000000000001E=http://[fd34:13d9:5468::2]:12004") + + agentURL, err := iosAgentURLOverride("00008101-00161CEC3CDB001E") + + require.NoError(t, err) + assert.Equal(t, "http://[fd34:13d9:5468::1]:12004", agentURL) +} + +func TestStartAgentTakesTheNormalPathForAnUnmappedDevice(t *testing.T) { + // the device's own agent already answers on the normal path, the mapped + // URL belongs to another device and nothing listens there + running := fakeAgent(t) + t.Setenv(iosAgentURLEnv, "00008101-00161CEC3CDB001E=http://127.0.0.1:1") + + device := deviceWithoutTunnel(running.URL) + + require.NoError(t, device.StartAgent(StartAgentConfig{})) + assert.Equal(t, running.URL, device.deviceKitClient.BaseURL()) +} + +func TestStartAgentRejectsABareURLWithTwoDevicesConnected(t *testing.T) { + withConnectedIOSDevices(t, 2) + agent := fakeAgent(t) + t.Setenv(iosAgentURLEnv, agent.URL) + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "2 iOS devices are connected") + assert.Contains(t, err.Error(), "=") + assert.Contains(t, err.Error(), iosAgentURLEnv) +} + func TestMjpegURLFollowsTheAgentURL(t *testing.T) { assert.Equal(t, "http://192.168.1.20:8100/mjpeg?fps=10", buildMjpegURLForAgent("http://192.168.1.20:8100", 10, 1)) assert.Equal(t, "http://[fd34:13d9:5468::1]:12004/mjpeg?fps=10", buildMjpegURLForAgent("http://[fd34:13d9:5468::1]:12004", 10, 1)) From 5c6a0cb0a2a2d2c039be201fb559c6d6adf968f7 Mon Sep 17 00:00:00 2001 From: Chris Scelza Date: Thu, 8 Oct 2026 11:52:16 -0400 Subject: [PATCH 3/4] fix(ios): accept a bare agent URL only with exactly one iOS device Co-Authored-By: Claude Opus 5.5 --- devices/ios_agent_url.go | 4 ++-- devices/ios_agent_url_test.go | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/devices/ios_agent_url.go b/devices/ios_agent_url.go index fc7b258e..b3ae1667 100644 --- a/devices/ios_agent_url.go +++ b/devices/ios_agent_url.go @@ -74,8 +74,8 @@ func bareAgentURL(value string) (string, error) { if err != nil { return "", err } - if count > 1 { - return "", fmt.Errorf("%s holds a single URL but %d iOS devices are connected, use = to say which device runs the agent", iosAgentURLEnv, count) + if count != 1 { + return "", fmt.Errorf("%s holds a single URL, which needs exactly one connected iOS device but found %d, use = to say which device runs the agent", iosAgentURLEnv, count) } return agentURL, nil diff --git a/devices/ios_agent_url_test.go b/devices/ios_agent_url_test.go index e53e0cd8..8a2788ec 100644 --- a/devices/ios_agent_url_test.go +++ b/devices/ios_agent_url_test.go @@ -2,6 +2,7 @@ package devices import ( "context" + "fmt" "net" "net/http" "net/http/httptest" @@ -201,17 +202,21 @@ func TestStartAgentTakesTheNormalPathForAnUnmappedDevice(t *testing.T) { assert.Equal(t, running.URL, device.deviceKitClient.BaseURL()) } -func TestStartAgentRejectsABareURLWithTwoDevicesConnected(t *testing.T) { - withConnectedIOSDevices(t, 2) - agent := fakeAgent(t) - t.Setenv(iosAgentURLEnv, agent.URL) +func TestStartAgentRejectsABareURLUnlessExactlyOneDeviceIsConnected(t *testing.T) { + for _, count := range []int{0, 2} { + t.Run(fmt.Sprintf("%d devices", count), func(t *testing.T) { + withConnectedIOSDevices(t, count) + agent := fakeAgent(t) + t.Setenv(iosAgentURLEnv, agent.URL) - err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "2 iOS devices are connected") - assert.Contains(t, err.Error(), "=") - assert.Contains(t, err.Error(), iosAgentURLEnv) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("found %d", count)) + assert.Contains(t, err.Error(), "=") + assert.Contains(t, err.Error(), iosAgentURLEnv) + }) + } } func TestMjpegURLFollowsTheAgentURL(t *testing.T) { From 98a6fdfd9be29b50f8c818c13aa9174aa001471f Mon Sep 17 00:00:00 2001 From: Chris Scelza Date: Wed, 14 Oct 2026 11:57:34 -0400 Subject: [PATCH 4/4] fix(ios): accept '=' and ',' in the path of a bare agent URL Classify a value that parses as a single http(s) URL as a bare URL before treating '=' and ',' as mapping separators. The README now says the agent serves plain HTTP, so the URL should be loopback or the CoreDevice tunnel. --- README.md | 2 +- devices/ios_agent_url.go | 12 ++++++++++-- devices/ios_agent_url_test.go | 21 +++++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9910ad70..adf16075 100644 --- a/README.md +++ b/README.md @@ -515,7 +515,7 @@ On Android, the daemon keeps a small server running on the device (`com.mobilene On a real iOS device, the daemon launches the on-device agent itself and reaches it through a tunnel (iOS 17+) and a port forward. If the agent is already running and reachable another way, set `MOBILECLI_IOS_AGENT_URL` to its URL and the daemon uses that agent as is, without launching it or starting a tunnel or forward for it. Commands that do not go through the agent (apps, logs, crashes, AVC streaming) are unchanged. -The variable takes either a bare URL, which mobilecli accepts only while exactly one real iOS device is connected, or a comma-separated list of `=` pairs such as `00008101-00161CEC3CDB001E=http://[fd34:13d9:5468::1]:12004,00008110-001A2B3C4D5E001E=http://localhost:8100`. With a list, each device uses only its own entry and a device without one is reached the usual way. The agent cannot report which device it runs on, so the mapping is what binds a device to its agent. The URL cannot have a query, a fragment, or a user name and password. The daemon reads the variable when it starts, so run `mobilecli daemon stop` after changing it. +The variable takes either a bare URL, which mobilecli accepts only while exactly one real iOS device is connected, or a comma-separated list of `=` pairs such as `00008101-00161CEC3CDB001E=http://[fd34:13d9:5468::1]:12004,00008110-001A2B3C4D5E001E=http://localhost:8100`. With a list, each device uses only its own entry and a device without one is reached the usual way. The agent cannot report which device it runs on, so the mapping is what binds a device to its agent. The URL cannot have a query, a fragment, or a user name and password. The agent serves plain HTTP with no authentication, so point the variable at loopback (a USB forward) or at the CoreDevice tunnel address below, not at the device's local network address. The daemon reads the variable when it starts, so run `mobilecli daemon stop` after changing it. For a device paired over Wi-Fi, the agent can listen on the Xcode CoreDevice tunnel address (devicekit-ios `DEVICEKIT_LISTEN_HOST`), which only the paired Mac can reach: diff --git a/devices/ios_agent_url.go b/devices/ios_agent_url.go index b3ae1667..d9571830 100644 --- a/devices/ios_agent_url.go +++ b/devices/ios_agent_url.go @@ -50,8 +50,9 @@ func iosAgentURLOverride(udid string) (string, error) { return "", nil } - // agent URLs cannot carry a query, so '=' and ',' only appear in a mapping - if !strings.ContainsAny(value, "=,") { + // a bare URL may have '=' or ',' in its path, so classify it before + // treating those characters as mapping separators + if isBareAgentURL(value) || !strings.ContainsAny(value, "=,") { return bareAgentURL(value) } @@ -62,6 +63,13 @@ func iosAgentURLOverride(udid string) (string, error) { return agentURLs[strings.ToUpper(udid)], nil } +// isBareAgentURL reports whether value parses as a single http(s) URL with a +// host. A = mapping never does: '=' cannot appear in a URL scheme. +func isBareAgentURL(value string) bool { + parsed, err := url.Parse(value) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} + // bareAgentURL accepts a URL without a udid only when it cannot reach the // wrong device, that is with a single real iOS device connected. func bareAgentURL(value string) (string, error) { diff --git a/devices/ios_agent_url_test.go b/devices/ios_agent_url_test.go index 8a2788ec..871a926d 100644 --- a/devices/ios_agent_url_test.go +++ b/devices/ios_agent_url_test.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/http/httptest" + "strings" "testing" "github.com/mobile-next/mobilecli/devices/devicekit" @@ -124,6 +125,7 @@ func TestStartAgentRejectsAQueryFragmentOrUserInfo(t *testing.T) { "http://localhost:8100/?": "cannot have a query or fragment", "http://localhost:8100/#": "cannot have a query or fragment", "http://localhost:8100/#health": "cannot have a query or fragment", + "http://localhost:8100?a=b": "cannot have a query or fragment", "http://user:secret@192.168.1.20:8100": "cannot carry a user name or password", "https://user@localhost:8100": "cannot carry a user name or password", "00008110-000000000000001E=http://u:p@h:81": "cannot carry a user name or password", @@ -140,10 +142,8 @@ func TestStartAgentRejectsAQueryFragmentOrUserInfo(t *testing.T) { } } -// a bare URL with a query looks like a mapping and is reported as malformed func TestStartAgentRejectsMalformedMappings(t *testing.T) { for _, value := range []string{ - "http://localhost:8100?a=b", "00008110-000000000000001E=", "=http://localhost:8100", "00008110-000000000000001E=http://localhost:8100,", @@ -224,3 +224,20 @@ func TestMjpegURLFollowsTheAgentURL(t *testing.T) { assert.Equal(t, "http://[fd34:13d9:5468::1]:12004/mjpeg?fps=10", buildMjpegURLForAgent("http://[fd34:13d9:5468::1]:12004", 10, 1)) assert.Equal(t, "http://localhost:8100/mjpeg?scale=50", buildMjpegURL(8100, 0, 0.5)) } + +// '=' and ',' in the path of a bare URL do not make it a mapping +func TestBareAgentURLMayHaveMappingSeparatorsInItsPath(t *testing.T) { + withConnectedIOSDevices(t, 1) + for _, value := range []string{ + "http://localhost:8100/proxy=v2", + "http://localhost:8100/a,b", + "https://[fd34:13d9:5468::1]:12004/x=1,y=2/", + } { + t.Setenv(iosAgentURLEnv, value) + + agentURL, err := iosAgentURLOverride("00008110-000000000000001E") + + require.NoError(t, err, value) + assert.Equal(t, strings.TrimSuffix(value, "/"), agentURL, value) + } +}