diff --git a/docs/INSTALL_IOS.md b/docs/INSTALL_IOS.md index 76a555d0..3d4ba79d 100644 --- a/docs/INSTALL_IOS.md +++ b/docs/INSTALL_IOS.md @@ -22,6 +22,10 @@ Success means the app shows the active account from the Mac gateway. The app stores these values locally on the device. +For the default Mac setup, choose **Cloudflare** and enter the Cloudflare Tunnel URL. The Mac gateway listens on `127.0.0.1:18790` by default, which is only reachable from the Mac itself. Use **Same Network** only after configuring the gateway to listen on a Mac LAN address. + +The gateway URL must include `http://` or `https://`. + ## App Store Connect Setup The local Fastlane credentials live in the ignored file: @@ -51,3 +55,7 @@ The iOS app can register for turn-finished notifications. APNs certificates or k ## File Uploads Files selected in the iOS app are uploaded to the Mac gateway and saved under the CodePilot state directory. Image attachments can be passed into Codex turns when supported by the current runner. + +## Remote Desktop + +Remote Desktop requires the Mac app to show Screen Recording as granted before viewing and Accessibility as granted before control. If pairing reports missing permissions, grant them in macOS System Settings, restart CodePilot on the Mac, then retry pairing from the iOS app. diff --git a/docs/INSTALL_MAC.md b/docs/INSTALL_MAC.md index b9a827cc..00cf8f92 100644 --- a/docs/INSTALL_MAC.md +++ b/docs/INSTALL_MAC.md @@ -57,6 +57,8 @@ The gateway listens on: http://127.0.0.1:18790 ``` +That local address is for CodePilot on the Mac and for Cloudflare Tunnel. A physical iPhone cannot reach `127.0.0.1` on the Mac directly; use Cloudflare remote access for the default setup, or deliberately start the gateway on a LAN address before using the iOS **Same Network** option. + The bearer token is stored at: ```text @@ -68,3 +70,7 @@ Copy this token into the iOS app connection screen. Do not share it in issue rep ## Remote iPhone Access For access away from the local network, open **Setup CodePilot...** in the Mac menu bar app and use **Cloudflare Remote Access**. The setup wizard can install `cloudflared`, sign in to Cloudflare, configure a permanent hostname, or start a temporary TryCloudflare URL for testing. + +## Remote Desktop Permissions + +Open **Remote Desktop...** from the Mac menu bar app to check Screen Recording and Accessibility. Screen Recording is required to view the Mac, and Accessibility is required for pointer and keyboard control. Restart CodePilot after granting either macOS privacy permission. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index ec83b207..f51e15fe 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -4087,14 +4087,14 @@ def do_GET(self): ) return + if path == "/health" or path == "/api/health": + json_response(self, 200, self.state().public_health()) + return + if not self.authenticate(): return try: - if path == "/health" or path == "/api/health": - json_response(self, 200, self.state().public_health()) - return - if path == "/api/threads": json_response(self, 200, { "activeAccount": self.state().active_account_name(), diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index ec80e6a8..e7ed1fea 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -3,7 +3,9 @@ import io import subprocess import tempfile +import threading import tomllib +import urllib.request import unittest from pathlib import Path @@ -836,6 +838,39 @@ def test_public_health_exposes_safe_gateway_status(self): self.assertIn("localWeb", health) self.assertNotIn("secret-token", json.dumps(health)) + def test_health_endpoint_is_available_without_bearer_token(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + server = None + thread = None + try: + gateway.DEFAULT_SWITCHER_HOME = switcher_home + switcher_home.mkdir() + codex_home.mkdir() + (switcher_home / "active-account.txt").write_text("main", encoding="utf-8") + state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False) + server = gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + url = f"http://127.0.0.1:{server.server_address[1]}/api/health" + with urllib.request.urlopen(url, timeout=2) as response: + status = response.status + payload = json.loads(response.read().decode("utf-8")) + finally: + if server is not None: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=2) + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + + self.assertEqual(status, 200) + self.assertTrue(payload["gateway"]["running"]) + self.assertNotIn("secret-token", json.dumps(payload)) + def test_error_payload_has_stable_code_message_and_recovery(self): payload = gateway.error_payload( "local_web_invalid_target", diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index 77fd87df..29c4c8f5 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -373,7 +373,7 @@ enum GatewayConnectionKind: String, CaseIterable, Identifiable { var helpText: String { switch self { case .local: - "Use the Mac gateway URL while your iPhone is on the same Wi-Fi network." + "Use only after configuring the Mac gateway to listen on a LAN address." case .cloudflare: "Use your Cloudflare Tunnel hostname for access when you are away from the Mac." } @@ -1726,8 +1726,20 @@ func isMacLocalWebURL(_ url: URL) -> Bool { return host == "localhost" || host == "127.0.0.1" || host == "::1" } +func gatewayRootURL(from rawValue: String) throws -> URL { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = url.host, + !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw GatewayError.invalidURL + } + return url +} + func localWebSessionURL(path: String, baseURL: String) -> URL? { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)), + guard let root = try? gatewayRootURL(from: baseURL), var components = URLComponents(url: root, resolvingAgainstBaseURL: false) else { return nil } @@ -4633,9 +4645,7 @@ struct CodexGatewayClient { } func downloadRemoteFile(path: String, baseURL: String, token: String) async throws -> URL { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { - throw GatewayError.invalidURL - } + let root = try gatewayRootURL(from: baseURL) guard var components = URLComponents(url: root, resolvingAgainstBaseURL: false) else { throw GatewayError.invalidURL } @@ -4696,9 +4706,7 @@ struct CodexGatewayClient { baseURL: String, token: String ) async throws -> T { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { - throw GatewayError.invalidURL - } + let root = try gatewayRootURL(from: baseURL) guard let url = URL(string: path, relativeTo: root)?.absoluteURL else { throw GatewayError.invalidURL } diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift index 060136d2..98306811 100644 --- a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -17,7 +17,17 @@ final class RemoteDesktopTests: XCTestCase { func testGatewayConnectionKindUsesUserFacingTitles() { XCTAssertEqual(GatewayConnectionKind.local.title, "Same Network") XCTAssertEqual(GatewayConnectionKind.cloudflare.title, "Cloudflare") - XCTAssertTrue(GatewayConnectionKind.local.helpText.contains("same Wi-Fi")) + XCTAssertTrue(GatewayConnectionKind.local.helpText.contains("LAN address")) + XCTAssertTrue(GatewayConnectionKind.cloudflare.helpText.contains("Cloudflare Tunnel")) + } + + func testGatewayRootURLRequiresHTTPOrHTTPSWithHost() throws { + let url = try gatewayRootURL(from: " https://codepilot.example.com ") + + XCTAssertEqual(url.absoluteString, "https://codepilot.example.com") + assertInvalidGatewayRootURL("codepilot.example.com") + assertInvalidGatewayRootURL("file:///tmp/codepilot") + assertInvalidGatewayRootURL("http:///missing-host") } func testMacLocalWebURLDetectionOnlyAcceptsLoopbackHTTPURLs() throws { @@ -451,4 +461,17 @@ final class RemoteDesktopTests: XCTestCase { decoder.dataDecodingStrategy = .base64 return decoder } + + private func assertInvalidGatewayRootURL( + _ value: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError(try gatewayRootURL(from: value), file: file, line: line) { error in + guard case GatewayError.invalidURL = error else { + XCTFail("Expected GatewayError.invalidURL, got \(error)", file: file, line: line) + return + } + } + } } diff --git a/ops/agents/escalations/setup-audit.md b/ops/agents/escalations/setup-audit.md new file mode 100644 index 00000000..a96d4222 --- /dev/null +++ b/ops/agents/escalations/setup-audit.md @@ -0,0 +1,5 @@ +# Setup Audit Escalation + +Date: 2026-07-01 + +Remote Desktop pairing needs human review before public beta. The iOS pairing screen shows a pairing code field, but the current flow signs the challenge and the Mac approves the device immediately after `pairing.complete`; the Mac-side Approve/Reject UI is not part of the happy path. Because Remote Desktop controls the Mac, decide whether bearer-token possession is enough for pairing or whether pairing must require explicit Mac-side approval and clearer user copy. diff --git a/scripts/codepilot-agent-escalate.sh b/scripts/codepilot-agent-escalate.sh new file mode 100755 index 00000000..ca969283 --- /dev/null +++ b/scripts/codepilot-agent-escalate.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env zsh +set -euo pipefail + +ROOT="${CODEPILOT_REPO_ROOT:-/Users/homeserver/Developer/CodexAccountSwitcher}" +STATE_DIR="${CODEPILOT_AGENT_STATE_DIR:-$HOME/.codex-account-switcher/agents}" +THREAD_ID_FILE="$STATE_DIR/thread-id" +CODEX_BIN="${CODEPILOT_CODEX_BIN:-codex}" +JOB="${1:-manual}" +MESSAGE="${2:-}" + +mkdir -p "$STATE_DIR" + +THREAD_ID="${CODEPILOT_AGENT_THREAD_ID:-}" +if [[ -z "$THREAD_ID" && -f "$THREAD_ID_FILE" ]]; then + THREAD_ID="$(tr -d '[:space:]' < "$THREAD_ID_FILE")" +fi + +if [[ -z "$THREAD_ID" ]]; then + echo "No CodePilot agent thread id configured." >&2 + exit 66 +fi + +if [[ -z "$MESSAGE" ]]; then + MESSAGE="$(cat)" +fi + +{ + echo "A CodePilot continuous agent needs intervention." + echo + echo "Agent: $JOB" + echo + echo "$MESSAGE" +} | "$CODEX_BIN" exec resume "$THREAD_ID" -