Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct PendingPairing: Equatable {
let challenge: RemotePairingChallenge
let publicKeyRawRepresentation: Data
let keyFingerprint: String
var approvalToken: VerifiedPairingApprovalToken?
}

struct ActiveRemoteSession: Equatable {
Expand Down Expand Up @@ -139,24 +140,51 @@ final class RemoteDesktopCoordinator {
name: name,
challenge: challenge,
publicKeyRawRepresentation: publicKeyRawRepresentation,
keyFingerprint: Self.keyFingerprint(publicKeyRawRepresentation)
keyFingerprint: Self.keyFingerprint(publicKeyRawRepresentation),
approvalToken: nil
)
pendingPairing = pending
try appendAuditLocked(.pairingChallengeIssued, deviceID: deviceID, sessionID: nil, leaseID: nil, reason: nil)
rebuildSnapshotLocked()
return pending
}

func approvePendingPairing() throws {
func verifyPendingPairing(
challengeID: String,
deviceID: String,
signature: Data
) throws -> RemotePairingApprovalStatus {
lock.lock()
defer { lock.unlock() }

guard let pendingPairing else { return }
_ = try pairingStore.trustPreverifiedDevice(
id: pendingPairing.deviceID,
name: pendingPairing.name,
publicKeyRawRepresentation: pendingPairing.publicKeyRawRepresentation
guard var pendingPairing,
pendingPairing.challenge.id == challengeID,
pendingPairing.deviceID == deviceID else {
throw RemoteDesktopSecurityError.challengeUnknown
}
let token = try pairingStore.verifyChallenge(
pendingPairing.challenge,
deviceID: deviceID,
signature: signature
)
pendingPairing.approvalToken = token
self.pendingPairing = pendingPairing
try appendAuditLocked(.pairingChallengeVerified, deviceID: deviceID, sessionID: nil, leaseID: nil, reason: nil)
rebuildSnapshotLocked()
return RemotePairingApprovalStatus(
status: "pending_mac_approval",
challengeID: challengeID,
deviceID: deviceID,
macName: pendingPairing.challenge.macName
)
}

func approvePendingPairing() throws {
lock.lock()
defer { lock.unlock() }

guard let pendingPairing, let approvalToken = pendingPairing.approvalToken else { return }
_ = try pairingStore.approveDevice(using: approvalToken)
self.pendingPairing = nil
try appendAuditLocked(.pairingApproved, deviceID: pendingPairing.deviceID, sessionID: nil, leaseID: nil, reason: nil)
rebuildSnapshotLocked()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ struct RemotePairingChallenge: Codable, Equatable, Identifiable {
let expiresAt: Date
}

struct RemotePairingApprovalStatus: Codable, Equatable {
let status: String
let challengeID: String
let deviceID: String
let macName: String
}

struct TrustedRemoteDevice: Codable, Equatable, Identifiable {
let id: String
var name: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ final class RemoteDesktopWindowController: NSWindowController {
if let pending = snapshot.pendingPairing {
addSection("Pending Pairing", rows: [
"Device: \(pending.name)",
"Status: \(pending.approvalToken == nil ? "Waiting for iPhone proof" : "Ready for Mac approval")",
"Code: \(pending.challenge.code)",
"Expires: \(Self.dateFormatter.string(from: pending.challenge.expiresAt))",
"Key: \(pending.keyFingerprint)"
Expand Down
11 changes: 3 additions & 8 deletions Sources/CodexAccountSwitcher/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3354,17 +3354,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
let challengeID = try Self.remoteDesktopString(payload["challengeId"])
let deviceID = try Self.remoteDesktopString(payload["deviceId"])
let signature = try Self.remoteDesktopBase64(payload["signature"])
guard let challenge = coordinator.pairingStore.challenge(id: challengeID) else {
return Self.remoteDesktopRPCError(request.id, status: 410, code: "pairing_expired")
}
let token = try coordinator.pairingStore.verifyChallenge(
challenge,
let approval = try coordinator.verifyPendingPairing(
challengeID: challengeID,
deviceID: deviceID,
signature: signature
)
let device = try coordinator.pairingStore.approveDevice(using: token)
coordinator.refreshStatus()
return try Self.remoteDesktopRPCCodable(request.id, device)
return try Self.remoteDesktopRPCCodable(request.id, approval)

case "devices.list":
coordinator.refreshStatus()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ final class RemoteDesktopCoordinatorTests: XCTestCase {

func testPairingCanBeApprovedOrRejectedLocally() throws {
let coordinator = try makeCoordinator()
let publicKey = P256.Signing.PrivateKey().publicKey.rawRepresentation
let privateKey = P256.Signing.PrivateKey()
let publicKey = privateKey.publicKey.rawRepresentation

let pending = try coordinator.beginPairing(
deviceID: "phone-1",
Expand All @@ -39,12 +40,46 @@ final class RemoteDesktopCoordinatorTests: XCTestCase {
coordinator.rejectPendingPairing()
XCTAssertNil(coordinator.snapshot.pendingPairing)

_ = try coordinator.beginPairing(
let approvalPending = try coordinator.beginPairing(
deviceID: "phone-1",
name: "Beta iPhone",
publicKeyRawRepresentation: publicKey,
macName: "Mac host"
)
_ = try coordinator.verifyPendingPairing(
challengeID: approvalPending.challenge.id,
deviceID: "phone-1",
signature: try privateKey.signature(for: Data(approvalPending.challenge.code.utf8)).derRepresentation
)
try coordinator.approvePendingPairing()

XCTAssertNil(coordinator.snapshot.pendingPairing)
XCTAssertEqual(coordinator.snapshot.trustedDevices.map(\.id), ["phone-1"])
}

func testVerifiedPairingWaitsForMacApprovalBeforeTrustingDevice() throws {
let coordinator = try makeCoordinator()
let privateKey = P256.Signing.PrivateKey()
let pending = try coordinator.beginPairing(
deviceID: "phone-1",
name: "Beta iPhone",
publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation,
macName: "Mac host"
)

let approval = try coordinator.verifyPendingPairing(
challengeID: pending.challenge.id,
deviceID: "phone-1",
signature: try privateKey.signature(for: Data(pending.challenge.code.utf8)).derRepresentation
)

XCTAssertEqual(approval.status, "pending_mac_approval")
XCTAssertEqual(approval.challengeID, pending.challenge.id)
XCTAssertEqual(approval.deviceID, "phone-1")
XCTAssertEqual(approval.macName, "Mac host")
XCTAssertEqual(coordinator.snapshot.pendingPairing?.deviceID, "phone-1")
XCTAssertEqual(coordinator.snapshot.trustedDevices, [])

try coordinator.approvePendingPairing()

XCTAssertNil(coordinator.snapshot.pendingPairing)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ final class RemoteDesktopModelsTests: XCTestCase {
sequence: 11,
reason: .busy
)
let approvalStatus = RemotePairingApprovalStatus(
status: "pending_mac_approval",
challengeID: "challenge-1",
deviceID: device.id,
macName: "Office Mac"
)

try assertRoundTrip(RemoteInputKind.keyDown)
try assertRoundTrip(RemoteInputEvent(
Expand Down Expand Up @@ -74,6 +80,7 @@ final class RemoteDesktopModelsTests: XCTestCase {
macName: "Office Mac",
expiresAt: date
))
try assertRoundTrip(approvalStatus)
try assertRoundTrip(device)
try assertRoundTrip(RemoteDesktopLease(
id: "lease-1",
Expand Down
2 changes: 1 addition & 1 deletion docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Not yet. The name is provider-neutral, but the first beta is Codex-focused. Futu

## Do I need Cloudflare?

Only for access away from your local network. Same-network iPhone access can use a local gateway URL. Remote access should use HTTPS and a private gateway token.
Yes for the public beta iPhone setup path. The Mac gateway listens on local loopback by default, so the iPhone app uses a Cloudflare Tunnel URL until CodePilot provides an explicit LAN-binding mode with firewall and trust guidance.

## Does CodePilot send my Codex credentials to a hosted service?

Expand Down
6 changes: 3 additions & 3 deletions docs/INSTALL_IOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ CodePilot iOS connects to a Mac running the CodePilot gateway.
- A Mac running the CodePilot gateway.
- A reachable gateway URL.
- The gateway bearer token from the Mac.
- Optional: Cloudflare Tunnel for remote access outside the local network.
- Cloudflare Tunnel for the public beta connection path.

## Beta Installation

Expand All @@ -19,7 +19,7 @@ Developers building from source can open the Xcode project under `ios/CodexPhone

On first launch:

1. Choose **Same Network** or **Cloudflare**.
1. Choose **Cloudflare**.
2. Enter the gateway URL.
3. Enter the token from `~/.codex-account-switcher/phone-gateway-token` on the Mac.
4. Tap **Test Connection**.
Expand All @@ -28,7 +28,7 @@ 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.
For the public beta, 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. Same Network setup is disabled until CodePilot provides an explicit LAN-binding mode with firewall and trust guidance.

The gateway URL must include `http://` or `https://`.

Expand Down
22 changes: 20 additions & 2 deletions ios/CodexPhone/CodexPhone/CodexPhoneApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ struct EmptySettingsView: View {

Section("Gateway") {
Picker("Connection", selection: $gatewayConnectionKind) {
ForEach(GatewayConnectionKind.allCases) { kind in
ForEach(GatewayConnectionKind.publicBetaCases) { kind in
Text(kind.title).tag(kind.rawValue)
}
}
Expand Down Expand Up @@ -308,6 +308,11 @@ struct EmptySettingsView: View {
}
}
.navigationTitle("CodePilot")
.onAppear {
if !selectedConnectionKind.isPublicBetaAvailable {
gatewayConnectionKind = GatewayConnectionKind.cloudflare.rawValue
}
}
}

private var canTestConnection: Bool {
Expand Down Expand Up @@ -361,6 +366,19 @@ enum GatewayConnectionKind: String, CaseIterable, Identifiable {

var id: String { rawValue }

static var publicBetaCases: [GatewayConnectionKind] {
allCases.filter(\.isPublicBetaAvailable)
}

var isPublicBetaAvailable: Bool {
switch self {
case .local:
false
case .cloudflare:
true
}
}

var title: String {
switch self {
case .local:
Expand All @@ -373,7 +391,7 @@ enum GatewayConnectionKind: String, CaseIterable, Identifiable {
var helpText: String {
switch self {
case .local:
"Use only after configuring the Mac gateway to listen on a LAN address."
"Same Network is disabled for public beta until LAN binding has explicit firewall and trust guidance."
case .cloudflare:
"Use your Cloudflare Tunnel hostname for access when you are away from the Mac."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct RemoteDesktopAPI {
)
}

func completePairing(challengeID: String, deviceID: String, signature: Data) async throws -> TrustedRemoteDevice {
func completePairing(challengeID: String, deviceID: String, signature: Data) async throws -> RemotePairingApprovalStatus {
try await request(
"POST",
path: "/api/remote/pairing/complete",
Expand Down Expand Up @@ -191,6 +191,7 @@ struct RemoteDesktopHostStatus: Codable, Equatable {
let screenRecordingGranted: Bool?
let accessibilityGranted: Bool?
let macUnlocked: Bool?
let trustedDeviceCount: Int?
let displayFrame: RemoteDisplayFrame?
let cursor: RemoteCursorPosition?
let iceServers: [RemoteIceServer]?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ struct RemotePairingChallenge: Codable, Equatable, Identifiable {
let expiresAt: Date
}

struct RemotePairingApprovalStatus: Codable, Equatable {
let status: String
let challengeID: String
let deviceID: String
let macName: String
}

struct TrustedRemoteDevice: Codable, Equatable, Identifiable {
let id: String
var name: String
Expand Down
22 changes: 13 additions & 9 deletions ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,13 @@ struct RemotePairingView: View {
@AppStorage("gatewayToken") private var gatewayToken = ""
@State private var statusText = "Not paired"
@State private var isLoading = false
@State private var manualCode = ""
@State private var identity = SoftwareRemoteDeviceIdentity(deviceID: UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString)

var body: some View {
NavigationStack {
Form {
Section("Mac") {
Text(statusText)
TextField("Pairing code", text: $manualCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}

Section("Device") {
Expand All @@ -32,7 +28,7 @@ struct RemotePairingView: View {
} label: {
Label("Start Session", systemImage: "play.rectangle")
}
.disabled(statusText == "Not paired")
.disabled(!canStartRemoteDesktop(statusText: statusText))

Button {
Task { await refreshStatus() }
Expand Down Expand Up @@ -78,10 +74,12 @@ struct RemotePairingView: View {
status.accessibilityGranted == false ? "Accessibility" : nil
].compactMap { $0 }.joined(separator: " and ")
statusText = "Allow \(missing) for CodePilot on the Mac"
} else if (status.trustedDeviceCount ?? 0) > 0 {
statusText = "Paired with this Mac"
} else if status.capabilities?.relayAvailable == true {
statusText = "Host reachable, relay available"
statusText = "Not paired"
} else {
statusText = "Host reachable, local/STUN only"
statusText = "Not paired"
}
} catch {
statusText = "Host unavailable"
Expand All @@ -99,12 +97,14 @@ struct RemotePairingView: View {
publicKey: identity.publicKeyRawRepresentation
)
let signature = try identity.sign(Data(challenge.code.utf8))
_ = try await api.completePairing(
let approval = try await api.completePairing(
challengeID: challenge.id,
deviceID: identity.deviceID,
signature: signature
)
statusText = "Paired with \(challenge.macName)"
statusText = approval.status == "pending_mac_approval"
? "Waiting for approval on \(approval.macName)"
: "Pairing pending"
} catch RemoteDesktopAPIError.server(_, let code) {
statusText = code
} catch {
Expand All @@ -120,3 +120,7 @@ struct RemotePairingView: View {
return RemoteDesktopAPI(baseURL: url, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines))
}
}

func canStartRemoteDesktop(statusText: String) -> Bool {
statusText.hasPrefix("Paired with ")
}
Loading