diff --git a/.gitignore b/.gitignore index 381a6a23..a3d9d145 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ remote-projects-*.js # Generated OTA artifacts ios/CodexPhone/build/ ios/CodexPhone/fastlane/report.xml +.worktrees/ # macOS .DS_Store diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..ac7e11b5 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "webrtc-xcframework-static", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/webrtc-xcframework-static.git", + "state" : { + "revision" : "883f7f39f0733be564eb033d7fa6ce10960f7b7e", + "version" : "144.7559.10" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 846eadd4..e52f1073 100644 --- a/Package.swift +++ b/Package.swift @@ -7,9 +7,15 @@ let package = Package( platforms: [ .macOS(.v13) ], + dependencies: [ + .package(url: "https://github.com/livekit/webrtc-xcframework-static.git", exact: "144.7559.10") + ], targets: [ .executableTarget( - name: "CodexAccountSwitcher" + name: "CodexAccountSwitcher", + dependencies: [ + .product(name: "LiveKitWebRTC", package: "webrtc-xcframework-static") + ] ), .testTarget( name: "CodexAccountSwitcherTests", diff --git a/README.md b/README.md index 6621f71a..947109d4 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ See: - [Cloudflare Setup](docs/CLOUDFLARE_SETUP.md) - [Security](docs/SECURITY.md) - [Architecture](docs/ARCHITECTURE.md) +- [Troubleshooting](docs/TROUBLESHOOTING.md) +- [Release Checklist](docs/RELEASE_CHECKLIST.md) ## Features @@ -76,4 +78,3 @@ Screenshots will be added before the first public release. ## Public Release Status CodePilot is being prepared for broader publishing. Expect some internal names, bundle identifiers, LaunchAgent labels, and local state paths to still contain legacy Codex-specific names during the first migration phase. - diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/InputInjector.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/InputInjector.swift new file mode 100644 index 00000000..158bee58 --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/InputInjector.swift @@ -0,0 +1,182 @@ +import CoreGraphics +import Foundation + +enum DisplayCoordinateMapper { + static func map(x: Double, y: Double, into frame: CGRect) -> CGPoint { + let clampedX = min(1, max(0, x)) + let clampedY = min(1, max(0, y)) + return CGPoint( + x: frame.minX + frame.width * clampedX, + y: frame.minY + frame.height * clampedY + ) + } +} + +protocol RemoteInputLeaseValidating: AnyObject { + func validateInput(sessionID: String, sequence: UInt64) throws +} + +protocol RemoteInputPosting: AnyObject { + func currentPointerPosition() -> CGPoint + func movePointer(to point: CGPoint) + func buttonDown(_ button: Int, at point: CGPoint) + func buttonUp(_ button: Int, at point: CGPoint) + func scroll(deltaX: Double, deltaY: Double) + func keyDown(_ keyCode: UInt16) + func keyUp(_ keyCode: UInt16) + func text(_ text: String) +} + +final class RemoteInputInjector { + private let validator: RemoteInputLeaseValidating + private let sink: RemoteInputPosting + private var pressedKeys = Set() + private var pressedButtons: [Int: CGPoint] = [:] + + init(validator: RemoteInputLeaseValidating, sink: RemoteInputPosting = CGEventRemoteInputSink()) { + self.validator = validator + self.sink = sink + } + + func handle(_ event: RemoteInputEvent, displayFrame: CGRect) throws { + try validator.validateInput(sessionID: event.sessionId, sequence: event.sequence) + + switch event.kind { + case .pointer: + if let deltaX = event.deltaX, let deltaY = event.deltaY, event.x == nil, event.y == nil { + let current = sink.currentPointerPosition() + sink.movePointer(to: CGPoint( + x: min(displayFrame.maxX, max(displayFrame.minX, current.x + deltaX)), + y: min(displayFrame.maxY, max(displayFrame.minY, current.y + deltaY)) + )) + } else { + sink.movePointer(to: try point(for: event, displayFrame: displayFrame)) + } + case .buttonDown: + let button = event.button ?? 0 + let point = try pointOrCurrent(for: event, displayFrame: displayFrame) + if event.x != nil || event.y != nil { + sink.movePointer(to: point) + } + pressedButtons[button] = point + sink.buttonDown(button, at: point) + case .buttonUp: + let button = event.button ?? 0 + let point = try pointOrCurrent(for: event, displayFrame: displayFrame) + if event.x != nil || event.y != nil { + sink.movePointer(to: point) + } + pressedButtons.removeValue(forKey: button) + sink.buttonUp(button, at: point) + case .scroll: + sink.scroll(deltaX: event.deltaX ?? 0, deltaY: event.deltaY ?? 0) + case .keyDown: + guard let keyCode = event.keyCode else { throw RemoteDesktopSecurityError.invalidSignature } + pressedKeys.insert(keyCode) + sink.keyDown(keyCode) + case .keyUp: + guard let keyCode = event.keyCode else { throw RemoteDesktopSecurityError.invalidSignature } + pressedKeys.remove(keyCode) + sink.keyUp(keyCode) + case .text: + guard let text = event.text else { throw RemoteDesktopSecurityError.invalidSignature } + sink.text(text) + } + } + + func releaseAll() { + for key in pressedKeys.sorted() { + sink.keyUp(key) + } + pressedKeys.removeAll() + + for button in pressedButtons.keys.sorted() { + sink.buttonUp(button, at: pressedButtons[button] ?? .zero) + } + pressedButtons.removeAll() + } + + private func point(for event: RemoteInputEvent, displayFrame: CGRect) throws -> CGPoint { + guard let x = event.x, let y = event.y else { + throw RemoteDesktopSecurityError.invalidSignature + } + return DisplayCoordinateMapper.map(x: x, y: y, into: displayFrame) + } + + private func pointOrCurrent(for event: RemoteInputEvent, displayFrame: CGRect) throws -> CGPoint { + if event.x == nil, event.y == nil { + return sink.currentPointerPosition() + } + return try point(for: event, displayFrame: displayFrame) + } +} + +final class CGEventRemoteInputSink: RemoteInputPosting { + func currentPointerPosition() -> CGPoint { + CGEvent(source: nil)?.location ?? .zero + } + + func movePointer(to point: CGPoint) { + CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left)? + .post(tap: .cghidEventTap) + } + + func buttonDown(_ button: Int, at point: CGPoint) { + CGEvent(mouseEventSource: nil, mouseType: mouseType(for: button, down: true), mouseCursorPosition: point, mouseButton: mouseButton(for: button))? + .post(tap: .cghidEventTap) + } + + func buttonUp(_ button: Int, at point: CGPoint) { + CGEvent(mouseEventSource: nil, mouseType: mouseType(for: button, down: false), mouseCursorPosition: point, mouseButton: mouseButton(for: button))? + .post(tap: .cghidEventTap) + } + + func scroll(deltaX: Double, deltaY: Double) { + CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 2, + wheel1: Int32(deltaY), + wheel2: Int32(deltaX), + wheel3: 0 + )?.post(tap: .cghidEventTap) + } + + func keyDown(_ keyCode: UInt16) { + CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)? + .post(tap: .cghidEventTap) + } + + func keyUp(_ keyCode: UInt16) { + CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false)? + .post(tap: .cghidEventTap) + } + + func text(_ text: String) { + let utf16 = Array(text.utf16) + let event = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true) + utf16.withUnsafeBufferPointer { buffer in + event?.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: buffer.baseAddress) + } + event?.post(tap: .cghidEventTap) + } + + private func mouseButton(for button: Int) -> CGMouseButton { + switch button { + case 1: return .right + case 2: return .center + default: return .left + } + } + + private func mouseType(for button: Int, down: Bool) -> CGEventType { + switch mouseButton(for: button) { + case .right: + return down ? .rightMouseDown : .rightMouseUp + case .center: + return down ? .otherMouseDown : .otherMouseUp + default: + return down ? .leftMouseDown : .leftMouseUp + } + } +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift new file mode 100644 index 00000000..7d76e936 --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift @@ -0,0 +1,239 @@ +import CoreGraphics +import Foundation +import LiveKitWebRTC + +typealias RemoteInputHandler = (RemoteInputEvent) throws -> CGPoint? + +private struct MacPeerCursorUpdate: Codable { + struct Cursor: Codable { + let x: Double + let y: Double + } + + let cursor: Cursor +} + +enum MacPeerConnectionState: Equatable { + case idle + case connecting(leaseID: String) + case connected(leaseID: String) + case disconnected(leaseID: String) + case failed(leaseID: String) +} + +struct MacPeerSignal: Codable, Equatable { + enum Kind: String, Codable { + case offer + case answer + case ice + } + + let leaseID: String + let sequence: UInt64 + let kind: Kind + let payload: Data +} + +enum MacPeerConnectionError: Error { + case peerCreationFailed + case sessionDescriptionFailed +} + +final class MacPeerConnection: NSObject, LKRTCPeerConnectionDelegate, LKRTCDataChannelDelegate { + private(set) var state: MacPeerConnectionState = .idle + private var activeLeaseID: String? + private var lastRemoteSignalSequence: UInt64 = 0 + private var pendingLocalSignals: [MacPeerSignal] = [] + private let factory = LKRTCPeerConnectionFactory() + private let captureService = ScreenCaptureService() + private let inputHandler: RemoteInputHandler? + private var peerConnection: LKRTCPeerConnection? + private var inputDataChannel: LKRTCDataChannel? + private var videoSource: LKRTCVideoSource? + private var videoTrack: LKRTCVideoTrack? + private var frameAdapter: WebRTCFrameAdapter? + + init(inputHandler: RemoteInputHandler? = nil) { + self.inputHandler = inputHandler + super.init() + } + + func start(leaseID: String) { + activeLeaseID = leaseID + lastRemoteSignalSequence = 0 + pendingLocalSignals.removeAll() + state = .connecting(leaseID: leaseID) + } + + func markConnected() { + guard let activeLeaseID else { return } + state = .connected(leaseID: activeLeaseID) + } + + func answer(offerSDP: String) async throws -> String { + guard activeLeaseID != nil else { throw RemoteDesktopSecurityError.leaseUnknown } + let configuration = LKRTCConfiguration() + configuration.sdpSemantics = .unifiedPlan + configuration.continualGatheringPolicy = .gatherContinually + let constraints = LKRTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) + guard let peer = factory.peerConnection( + with: configuration, + constraints: constraints, + delegate: self + ) else { + throw MacPeerConnectionError.peerCreationFailed + } + peerConnection = peer + + let source = factory.videoSource(forScreenCast: true) + let track = factory.videoTrack(with: source, trackId: "codepilot-screen") + _ = peer.add(track, streamIds: ["codepilot-screen"]) + videoSource = source + videoTrack = track + let adapter = WebRTCFrameAdapter(delegate: source) + frameAdapter = adapter + try await captureService.start(displayID: CGMainDisplayID()) { [weak adapter] sampleBuffer in + adapter?.consume(sampleBuffer) + } + + let remote = LKRTCSessionDescription(type: .offer, sdp: offerSDP) + try await setRemoteDescription(remote, on: peer) + let answer = try await createAnswer(on: peer, constraints: constraints) + try await setLocalDescription(answer, on: peer) + try await Task.sleep(nanoseconds: 750_000_000) + markConnected() + return peer.localDescription?.sdp ?? answer.sdp + } + + func acceptRemoteSignal(_ signal: MacPeerSignal) throws { + guard signal.leaseID == activeLeaseID else { + throw RemoteDesktopSecurityError.leaseUnknown + } + guard signal.sequence > lastRemoteSignalSequence else { + throw RemoteDesktopSecurityError.sequenceReplay + } + lastRemoteSignalSequence = signal.sequence + } + + func enqueueLocalSignal(kind: MacPeerSignal.Kind, payload: Data) throws { + guard let activeLeaseID else { + throw RemoteDesktopSecurityError.leaseUnknown + } + let sequence = UInt64(pendingLocalSignals.count + 1) + pendingLocalSignals.append(MacPeerSignal( + leaseID: activeLeaseID, + sequence: sequence, + kind: kind, + payload: payload + )) + } + + func drainLocalSignals() -> [MacPeerSignal] { + let signals = pendingLocalSignals + pendingLocalSignals.removeAll() + return signals + } + + func disconnect() { + captureService.stop() + inputDataChannel?.delegate = nil + inputDataChannel?.close() + inputDataChannel = nil + peerConnection?.close() + peerConnection = nil + frameAdapter = nil + videoTrack = nil + videoSource = nil + if let activeLeaseID { + state = .disconnected(leaseID: activeLeaseID) + } else { + state = .idle + } + activeLeaseID = nil + pendingLocalSignals.removeAll() + lastRemoteSignalSequence = 0 + } + + func fail() { + captureService.stop() + inputDataChannel?.delegate = nil + inputDataChannel?.close() + inputDataChannel = nil + peerConnection?.close() + peerConnection = nil + frameAdapter = nil + videoTrack = nil + videoSource = nil + if let activeLeaseID { + state = .failed(leaseID: activeLeaseID) + } else { + state = .idle + } + activeLeaseID = nil + pendingLocalSignals.removeAll() + lastRemoteSignalSequence = 0 + } + + private func setRemoteDescription(_ description: LKRTCSessionDescription, on peer: LKRTCPeerConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + peer.setRemoteDescription(description) { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + } + } + } + + private func createAnswer( + on peer: LKRTCPeerConnection, + constraints: LKRTCMediaConstraints + ) async throws -> LKRTCSessionDescription { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + peer.answer(for: constraints) { description, error in + if let error { continuation.resume(throwing: error) } + else if let description { continuation.resume(returning: description) } + else { continuation.resume(throwing: MacPeerConnectionError.sessionDescriptionFailed) } + } + } + } + + private func setLocalDescription(_ description: LKRTCSessionDescription, on peer: LKRTCPeerConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + peer.setLocalDescription(description) { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + } + } + } + + func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange stateChanged: LKRTCSignalingState) {} + func peerConnection(_ peerConnection: LKRTCPeerConnection, didAdd stream: LKRTCMediaStream) {} + func peerConnection(_ peerConnection: LKRTCPeerConnection, didRemove stream: LKRTCMediaStream) {} + func peerConnectionShouldNegotiate(_ peerConnection: LKRTCPeerConnection) {} + func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange newState: LKRTCIceConnectionState) {} + func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange newState: LKRTCIceGatheringState) {} + func peerConnection(_ peerConnection: LKRTCPeerConnection, didGenerate candidate: LKRTCIceCandidate) {} + func peerConnection(_ peerConnection: LKRTCPeerConnection, didRemove candidates: [LKRTCIceCandidate]) {} + func peerConnection(_ peerConnection: LKRTCPeerConnection, didOpen dataChannel: LKRTCDataChannel) { + guard dataChannel.label == "codepilot-input" else { return } + inputDataChannel = dataChannel + dataChannel.delegate = self + } + + func dataChannelDidChangeState(_ dataChannel: LKRTCDataChannel) {} + + func dataChannel(_ dataChannel: LKRTCDataChannel, didReceiveMessageWith buffer: LKRTCDataBuffer) { + guard !buffer.isBinary else { return } + do { + let event = try JSONDecoder().decode(RemoteInputEvent.self, from: buffer.data) + guard let cursor = try inputHandler?(event) else { return } + let update = MacPeerCursorUpdate(cursor: MacPeerCursorUpdate.Cursor( + x: Double(cursor.x), + y: Double(cursor.y) + )) + let data = try JSONEncoder().encode(update) + _ = dataChannel.sendData(LKRTCDataBuffer(data: data, isBinary: false)) + } catch { + NSLog("CodePilot remote desktop data-channel input failed: \(error.localizedDescription)") + } + } +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/PairingStore.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/PairingStore.swift new file mode 100644 index 00000000..e838e9ed --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/PairingStore.swift @@ -0,0 +1,424 @@ +import CryptoKit +import Foundation + +enum RemoteDesktopSecurityError: Error, Equatable, LocalizedError { + case challengeUnknown + case challengeExpired + case challengeAlreadyUsed + case invalidSignature + case untrustedDevice + case deviceRevoked + case nonceUnknown + case nonceExpired + case nonceAlreadyUsed + case controllerBusy + case leaseUnknown + case leaseExpired + case sequenceReplay + case prohibitedAuditContent + + var errorDescription: String? { + switch self { + case .challengeUnknown: + return "Unknown pairing challenge." + case .challengeExpired: + return "Pairing challenge expired." + case .challengeAlreadyUsed: + return "Pairing challenge already used." + case .invalidSignature: + return "Invalid signature." + case .untrustedDevice: + return "Untrusted device." + case .deviceRevoked: + return "Device revoked." + case .nonceUnknown: + return "Unknown nonce." + case .nonceExpired: + return "Nonce expired." + case .nonceAlreadyUsed: + return "Nonce already used." + case .controllerBusy: + return "A controller is already active." + case .leaseUnknown: + return "Unknown lease." + case .leaseExpired: + return "Lease expired." + case .sequenceReplay: + return "Sequence replay rejected." + case .prohibitedAuditContent: + return "Prohibited audit content." + } + } +} + +struct VerifiedPairingApprovalToken: Equatable { + let id: UUID + let challengeID: String + let deviceID: String + let issuedAt: Date + + fileprivate init(challengeID: String, deviceID: String, issuedAt: Date) { + self.id = UUID() + self.challengeID = challengeID + self.deviceID = deviceID + self.issuedAt = issuedAt + } +} + +struct VerifiedRemoteDesktopNonce: Equatable { + let id: UUID + let nonce: String + let deviceID: String + let verifiedAt: Date + let expiresAt: Date + + fileprivate init(nonce: String, deviceID: String, verifiedAt: Date, expiresAt: Date) { + self.id = UUID() + self.nonce = nonce + self.deviceID = deviceID + self.verifiedAt = verifiedAt + self.expiresAt = expiresAt + } +} + +private struct PairingChallengeRecord: Equatable { + let challenge: RemotePairingChallenge + let deviceID: String + let deviceName: String + let publicKeyRawRepresentation: Data + let issuedAt: Date + var verifiedAt: Date? + var approvalToken: VerifiedPairingApprovalToken? + var approvalConsumedAt: Date? +} + +private struct VerifiedNonceRecord: Equatable { + let nonce: String + let deviceID: String + let verifiedAt: Date + let expiresAt: Date +} + +private struct NonceReplayKey: Hashable { + let deviceID: String + let nonce: String +} + +final class PairingStore { + typealias Clock = () -> Date + typealias DevicePersistence = ([TrustedRemoteDevice]) throws -> Void + + private let clock: Clock + private let fileURL: URL + private let persistTrustedDevices: DevicePersistence + private let lock = NSLock() + private var trustedDevicesByID: [String: TrustedRemoteDevice] + private var pairingChallengesByID: [String: PairingChallengeRecord] = [:] + private var verifiedNonceRecordsByKey: [NonceReplayKey: VerifiedNonceRecord] = [:] + + init( + fileURL: URL? = nil, + clock: @escaping Clock = Date.init, + persistTrustedDevices: DevicePersistence? = nil + ) throws { + self.clock = clock + let resolvedFileURL = fileURL ?? Self.defaultTrustedDevicesURL() + self.fileURL = resolvedFileURL + self.persistTrustedDevices = persistTrustedDevices ?? { devices in + try Self.persistTrustedDevices(devices, to: resolvedFileURL) + } + self.trustedDevicesByID = [:] + try Self.ensureStorage(for: resolvedFileURL) + self.trustedDevicesByID = try Self.loadTrustedDevices(from: resolvedFileURL).reduce(into: [:]) { result, device in + result[device.id] = device + } + } + + func issueChallenge( + deviceID: String, + name: String, + publicKeyRawRepresentation: Data, + macName: String + ) -> RemotePairingChallenge { + lock.lock() + defer { lock.unlock() } + + let now = clock() + let challenge = RemotePairingChallenge( + id: UUID().uuidString, + code: Self.randomChallengeCode(), + macName: macName, + expiresAt: now.addingTimeInterval(Self.challengeTTL) + ) + pairingChallengesByID[challenge.id] = PairingChallengeRecord( + challenge: challenge, + deviceID: deviceID, + deviceName: name, + publicKeyRawRepresentation: publicKeyRawRepresentation, + issuedAt: now, + verifiedAt: nil, + approvalToken: nil, + approvalConsumedAt: nil + ) + return challenge + } + + func verifyChallenge( + _ challenge: RemotePairingChallenge, + deviceID: String, + signature: Data + ) throws -> VerifiedPairingApprovalToken { + lock.lock() + defer { lock.unlock() } + + let now = clock() + guard var record = pairingChallengesByID[challenge.id], record.deviceID == deviceID else { + throw RemoteDesktopSecurityError.challengeUnknown + } + guard record.verifiedAt == nil else { + throw RemoteDesktopSecurityError.challengeAlreadyUsed + } + guard now <= record.challenge.expiresAt else { + throw RemoteDesktopSecurityError.challengeExpired + } + + let publicKey = try P256.Signing.PublicKey(rawRepresentation: record.publicKeyRawRepresentation) + let challengeSignature = try P256.Signing.ECDSASignature(derRepresentation: signature) + guard publicKey.isValidSignature(challengeSignature, for: Data(record.challenge.code.utf8)) else { + throw RemoteDesktopSecurityError.invalidSignature + } + + let token = VerifiedPairingApprovalToken( + challengeID: challenge.id, + deviceID: deviceID, + issuedAt: now + ) + record.verifiedAt = now + record.approvalToken = token + pairingChallengesByID[challenge.id] = record + return token + } + + func challenge(id: String) -> RemotePairingChallenge? { + lock.lock() + defer { lock.unlock() } + return pairingChallengesByID[id]?.challenge + } + + func approveDevice(using token: VerifiedPairingApprovalToken) throws -> TrustedRemoteDevice { + lock.lock() + defer { lock.unlock() } + + let now = clock() + guard var record = pairingChallengesByID[token.challengeID], record.deviceID == token.deviceID else { + throw RemoteDesktopSecurityError.challengeUnknown + } + guard let approvalToken = record.approvalToken, approvalToken.id == token.id else { + throw RemoteDesktopSecurityError.challengeAlreadyUsed + } + guard record.approvalConsumedAt == nil else { + throw RemoteDesktopSecurityError.challengeAlreadyUsed + } + guard now <= record.challenge.expiresAt else { + throw RemoteDesktopSecurityError.challengeExpired + } + + let approvedDevice = TrustedRemoteDevice( + id: record.deviceID, + name: record.deviceName, + publicKeyRawRepresentation: record.publicKeyRawRepresentation, + approvedAt: now, + revokedAt: nil + ) + var updatedDevices = trustedDevicesByID + updatedDevices[approvedDevice.id] = approvedDevice + try persistTrustedDevices(updatedDevices.values.sorted(by: { $0.id < $1.id })) + trustedDevicesByID = updatedDevices + record.approvalConsumedAt = now + pairingChallengesByID[token.challengeID] = record + return approvedDevice + } + + func verifyNonce(deviceID: String, nonce: String, signature: Data) throws -> VerifiedRemoteDesktopNonce { + lock.lock() + defer { lock.unlock() } + + purgeExpiredNonceRecords() + + let now = clock() + let key = NonceReplayKey(deviceID: deviceID, nonce: nonce) + if let record = verifiedNonceRecordsByKey[key], record.expiresAt > now { + throw RemoteDesktopSecurityError.nonceAlreadyUsed + } + + guard let device = trustedDevicesByID[deviceID] else { + throw RemoteDesktopSecurityError.untrustedDevice + } + guard device.revokedAt == nil else { + throw RemoteDesktopSecurityError.deviceRevoked + } + try Self.verify(signature: signature, message: Data(nonce.utf8), device: device) + + let proof = VerifiedRemoteDesktopNonce( + nonce: nonce, + deviceID: deviceID, + verifiedAt: now, + expiresAt: now.addingTimeInterval(Self.nonceProofTTL) + ) + verifiedNonceRecordsByKey[key] = VerifiedNonceRecord( + nonce: nonce, + deviceID: deviceID, + verifiedAt: now, + expiresAt: proof.expiresAt + ) + return proof + } + + func verify(signature: Data, message: Data, deviceID: String) throws { + guard let device = trustedDevice(id: deviceID) else { + throw RemoteDesktopSecurityError.untrustedDevice + } + try Self.verify(signature: signature, message: message, device: device) + } + + func trustedDevice(id: String) -> TrustedRemoteDevice? { + lock.lock() + defer { lock.unlock() } + return trustedDevicesByID[id] + } + + func trustedDevices() -> [TrustedRemoteDevice] { + lock.lock() + defer { lock.unlock() } + return trustedDevicesByID.values.sorted(by: { $0.id < $1.id }) + } + + @discardableResult + func trustPreverifiedDevice( + id: String, + name: String, + publicKeyRawRepresentation: Data + ) throws -> TrustedRemoteDevice { + lock.lock() + defer { lock.unlock() } + + let device = TrustedRemoteDevice( + id: id, + name: name, + publicKeyRawRepresentation: publicKeyRawRepresentation, + approvedAt: clock(), + revokedAt: nil + ) + var updatedDevices = trustedDevicesByID + updatedDevices[id] = device + try persistTrustedDevices(updatedDevices.values.sorted(by: { $0.id < $1.id })) + trustedDevicesByID = updatedDevices + return device + } + + @discardableResult + func revokeDevice(id: String) throws -> TrustedRemoteDevice { + lock.lock() + defer { lock.unlock() } + + guard var device = trustedDevicesByID[id] else { + throw RemoteDesktopSecurityError.untrustedDevice + } + + device.revokedAt = clock() + var updatedDevices = trustedDevicesByID + updatedDevices[id] = device + try persistTrustedDevices(updatedDevices.values.sorted(by: { $0.id < $1.id })) + trustedDevicesByID = updatedDevices + return device + } + + private static let challengeTTL: TimeInterval = 120 + private static let nonceProofTTL: TimeInterval = 60 + + private static func defaultTrustedDevicesURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codepilot/remote-desktop/trusted-devices.json") + } + + private static func randomChallengeCode() -> String { + var generator = SystemRandomNumberGenerator() + let bytes = (0..<32).map { _ in UInt8.random(in: .min ... .max, using: &generator) } + return Data(bytes).base64EncodedString() + } + + private static func ensureStorage(for fileURL: URL) throws { + let directoryURL = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: Data(), attributes: [.posixPermissions: 0o600]) + } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } + + private static func loadTrustedDevices(from fileURL: URL) throws -> [TrustedRemoteDevice] { + let data = try Data(contentsOf: fileURL) + guard !data.isEmpty else { + return [] + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.dataDecodingStrategy = .base64 + return try decoder.decode([TrustedRemoteDevice].self, from: data) + } + + private static func persistTrustedDevices(_ devices: [TrustedRemoteDevice], to fileURL: URL) throws { + let directoryURL = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.dataEncodingStrategy = .base64 + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(devices) + + let tempURL = directoryURL.appendingPathComponent(".\(UUID().uuidString).trusted-devices.json.tmp") + defer { + try? FileManager.default.removeItem(at: tempURL) + } + + guard FileManager.default.createFile(atPath: tempURL.path, contents: data, attributes: [.posixPermissions: 0o600]) else { + throw CocoaError(.fileWriteUnknown) + } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tempURL.path) + + let _ = try FileManager.default.replaceItemAt( + fileURL, + withItemAt: tempURL, + backupItemName: nil + ) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } + + private static func verify(signature: Data, message: Data, device: TrustedRemoteDevice) throws { + guard device.revokedAt == nil else { + throw RemoteDesktopSecurityError.deviceRevoked + } + + let key = try P256.Signing.PublicKey(rawRepresentation: device.publicKeyRawRepresentation) + let signature = try P256.Signing.ECDSASignature(derRepresentation: signature) + guard key.isValidSignature(signature, for: message) else { + throw RemoteDesktopSecurityError.invalidSignature + } + } + + private func purgeExpiredNonceRecords() { + let now = clock() + verifiedNonceRecordsByKey = verifiedNonceRecordsByKey.filter { $0.value.expiresAt > now } + } +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopAuditLog.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopAuditLog.swift new file mode 100644 index 00000000..a68b2f2c --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopAuditLog.swift @@ -0,0 +1,149 @@ +import Foundation + +final class RemoteDesktopAuditLog { + typealias Clock = () -> Date + + private let fileURL: URL + private let clock: Clock + private let lock = NSLock() + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(fileURL: URL? = nil, clock: @escaping Clock = Date.init) throws { + self.fileURL = fileURL ?? Self.defaultAuditLogURL() + self.clock = clock + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + self.encoder.dateEncodingStrategy = .iso8601 + self.encoder.outputFormatting = [.sortedKeys] + self.decoder.dateDecodingStrategy = .iso8601 + try Self.ensureStorage(for: self.fileURL) + } + + func append(_ event: RemoteDesktopAuditEvent) throws { + lock.lock() + defer { lock.unlock() } + + try Self.validate(event) + try Self.ensureStorage(for: fileURL) + var stamped = event + if stamped.timestamp == .distantPast { + stamped = RemoteDesktopAuditEvent( + id: event.id, + timestamp: clock(), + kind: event.kind, + deviceId: event.deviceId, + sessionId: event.sessionId, + leaseId: event.leaseId, + sequence: event.sequence, + reason: event.reason + ) + } + + let line = try encoder.encode(stamped) + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: line) + try handle.write(contentsOf: Data([0x0A])) + try Self.ensureFilePermissions(fileURL) + } + + func loadAll() throws -> [RemoteDesktopAuditEvent] { + lock.lock() + defer { lock.unlock() } + + let contents = try Data(contentsOf: fileURL) + guard !contents.isEmpty else { + return [] + } + + let segments = contents.split(separator: UInt8(0x0A), omittingEmptySubsequences: false) + let hasTrailingNewline = contents.last == UInt8(0x0A) + var events: [RemoteDesktopAuditEvent] = [] + + for (index, segment) in segments.enumerated() { + if segment.isEmpty { + continue + } + do { + events.append(try decoder.decode(RemoteDesktopAuditEvent.self, from: Data(segment))) + } catch { + let isLastSegment = index == segments.count - 1 + guard isLastSegment, !hasTrailingNewline else { + throw error + } + break + } + } + + return events + } + + private static func defaultAuditLogURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codepilot/remote-desktop/audit.jsonl") + } + + private static func ensureStorage(for fileURL: URL) throws { + let directoryURL = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try ensureDirectoryPermissions(directoryURL) + + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: Data(), attributes: [.posixPermissions: 0o600]) + } + try ensureFilePermissions(fileURL) + } + + private static func ensureDirectoryPermissions(_ url: URL) throws { + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) + } + + private static func ensureFilePermissions(_ url: URL) throws { + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + private static func validate(_ event: RemoteDesktopAuditEvent) throws { + let identifiers = [ + event.id, + event.deviceId, + event.sessionId, + event.leaseId + ].compactMap { $0 } + + guard identifiers.allSatisfy({ isAllowedIdentifier($0) }) else { + throw RemoteDesktopSecurityError.prohibitedAuditContent + } + } + + private static func isAllowedIdentifier(_ value: String) -> Bool { + let normalized = value.lowercased() + let prohibitedMarkers = [ + "screen content", + "screencontent", + "keystroke", + "clipboard value", + "clipboardvalue", + "bearer", + "private key", + "privatekey", + "turn secret", + "turnsecret", + "file contents", + "filecontents", + "password", + "secret", + "token" + ] + + guard !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { + return false + } + return !prohibitedMarkers.contains(where: { normalized.contains($0) }) + } +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift new file mode 100644 index 00000000..9f1a6610 --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift @@ -0,0 +1,245 @@ +import AppKit +import ApplicationServices +import CryptoKit +import Foundation + +protocol RemoteDesktopPermissionChecking: AnyObject { + var screenRecordingGranted: Bool { get } + var accessibilityGranted: Bool { get } + var macUnlocked: Bool { get } +} + +final class SystemRemoteDesktopPermissions: RemoteDesktopPermissionChecking { + var screenRecordingGranted: Bool { + CGPreflightScreenCaptureAccess() + } + + var accessibilityGranted: Bool { + AXIsProcessTrustedWithOptions(nil) + } + + var macUnlocked: Bool { + true + } + + func requestScreenRecording() { + _ = CGRequestScreenCaptureAccess() + } + + func requestAccessibility() { + let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } +} + +struct PendingPairing: Equatable { + let deviceID: String + let name: String + let challenge: RemotePairingChallenge + let publicKeyRawRepresentation: Data + let keyFingerprint: String +} + +struct ActiveRemoteSession: Equatable { + let deviceID: String + let leaseID: String + let startedAt: Date +} + +struct RemoteDesktopStatus: Equatable { + let screenRecordingGranted: Bool + let accessibilityGranted: Bool + let macUnlocked: Bool + let pendingPairing: PendingPairing? + let trustedDevices: [TrustedRemoteDevice] + let activeSession: ActiveRemoteSession? + let auditEvents: [RemoteDesktopAuditEvent] +} + +final class RemoteDesktopCoordinator { + let pairingStore: PairingStore + + private let permissions: RemoteDesktopPermissionChecking + private let auditLog: RemoteDesktopAuditLog + private let lock = NSLock() + private var lockObserver: NSObjectProtocol? + private var unlockObserver: NSObjectProtocol? + private var pendingPairing: PendingPairing? + private var activeSession: ActiveRemoteSession? + + private(set) var snapshot: RemoteDesktopStatus + + init( + permissions: RemoteDesktopPermissionChecking = SystemRemoteDesktopPermissions(), + pairingStore: PairingStore? = nil, + auditLog: RemoteDesktopAuditLog? = nil + ) throws { + self.permissions = permissions + self.pairingStore = try pairingStore ?? PairingStore() + self.auditLog = try auditLog ?? RemoteDesktopAuditLog() + self.snapshot = RemoteDesktopStatus( + screenRecordingGranted: permissions.screenRecordingGranted, + accessibilityGranted: permissions.accessibilityGranted, + macUnlocked: permissions.macUnlocked, + pendingPairing: nil, + trustedDevices: self.pairingStore.trustedDevices(), + activeSession: nil, + auditEvents: (try? self.auditLog.loadAll()) ?? [] + ) + refreshStatus() + lockObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleMacDidLock() + } + unlockObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshStatus() + } + } + + deinit { + if let lockObserver { + NSWorkspace.shared.notificationCenter.removeObserver(lockObserver) + } + if let unlockObserver { + NSWorkspace.shared.notificationCenter.removeObserver(unlockObserver) + } + } + + func refreshStatus() { + lock.lock() + defer { lock.unlock() } + rebuildSnapshotLocked() + } + + @discardableResult + func beginPairing( + deviceID: String, + name: String, + publicKeyRawRepresentation: Data, + macName: String + ) throws -> PendingPairing { + lock.lock() + defer { lock.unlock() } + + let challenge = pairingStore.issueChallenge( + deviceID: deviceID, + name: name, + publicKeyRawRepresentation: publicKeyRawRepresentation, + macName: macName + ) + let pending = PendingPairing( + deviceID: deviceID, + name: name, + challenge: challenge, + publicKeyRawRepresentation: publicKeyRawRepresentation, + keyFingerprint: Self.keyFingerprint(publicKeyRawRepresentation) + ) + pendingPairing = pending + try appendAuditLocked(.pairingChallengeIssued, deviceID: deviceID, sessionID: nil, leaseID: nil, reason: nil) + rebuildSnapshotLocked() + return pending + } + + func approvePendingPairing() throws { + lock.lock() + defer { lock.unlock() } + + guard let pendingPairing else { return } + _ = try pairingStore.trustPreverifiedDevice( + id: pendingPairing.deviceID, + name: pendingPairing.name, + publicKeyRawRepresentation: pendingPairing.publicKeyRawRepresentation + ) + self.pendingPairing = nil + try appendAuditLocked(.pairingApproved, deviceID: pendingPairing.deviceID, sessionID: nil, leaseID: nil, reason: nil) + rebuildSnapshotLocked() + } + + func rejectPendingPairing() { + lock.lock() + defer { lock.unlock() } + pendingPairing = nil + rebuildSnapshotLocked() + } + + func registerActiveSession(deviceID: String, leaseID: String, startedAt: Date = Date()) { + lock.lock() + defer { lock.unlock() } + activeSession = ActiveRemoteSession(deviceID: deviceID, leaseID: leaseID, startedAt: startedAt) + try? appendAuditLocked(.leaseGranted, deviceID: deviceID, sessionID: nil, leaseID: leaseID, reason: nil) + rebuildSnapshotLocked() + } + + func emergencyDisconnect() { + lock.lock() + defer { lock.unlock() } + if let activeSession { + try? appendAuditLocked(.leaseEnded, deviceID: activeSession.deviceID, sessionID: nil, leaseID: activeSession.leaseID, reason: nil) + } + activeSession = nil + rebuildSnapshotLocked() + } + + func revokeDevice(id: String) throws { + lock.lock() + defer { lock.unlock() } + _ = try pairingStore.revokeDevice(id: id) + if activeSession?.deviceID == id { + activeSession = nil + } + try appendAuditLocked(.deviceRevoked, deviceID: id, sessionID: nil, leaseID: nil, reason: .revoked) + rebuildSnapshotLocked() + } + + func handleMacDidLock() { + lock.lock() + defer { lock.unlock() } + activeSession = nil + rebuildSnapshotLocked() + } + + private func rebuildSnapshotLocked() { + snapshot = RemoteDesktopStatus( + screenRecordingGranted: permissions.screenRecordingGranted, + accessibilityGranted: permissions.accessibilityGranted, + macUnlocked: permissions.macUnlocked, + pendingPairing: pendingPairing, + trustedDevices: pairingStore.trustedDevices(), + activeSession: permissions.macUnlocked ? activeSession : nil, + auditEvents: (try? auditLog.loadAll()) ?? [] + ) + } + + private func appendAuditLocked( + _ kind: RemoteDesktopAuditEventKind, + deviceID: String?, + sessionID: String?, + leaseID: String?, + reason: RemoteDesktopAuditReason? + ) throws { + try auditLog.append(RemoteDesktopAuditEvent( + id: UUID().uuidString, + timestamp: .distantPast, + kind: kind, + deviceId: deviceID, + sessionId: sessionID, + leaseId: leaseID, + sequence: nil, + reason: reason + )) + } + + private static func keyFingerprint(_ data: Data) -> String { + SHA256.hash(data: data) + .prefix(8) + .map { String(format: "%02x", $0) } + .joined(separator: ":") + } +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift new file mode 100644 index 00000000..988d16ab --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift @@ -0,0 +1,141 @@ +import Foundation + +enum RemoteInputKind: String, Codable { + case pointer + case buttonDown + case buttonUp + case scroll + case keyDown + case keyUp + case text +} + +struct RemoteInputEvent: Codable, Equatable { + let sessionId: String + let sequence: UInt64 + let kind: RemoteInputKind + let x: Double? + let y: Double? + let button: Int? + let keyCode: UInt16? + let text: String? + let deltaX: Double? + let deltaY: Double? +} + +struct RemoteDisplay: Codable, Equatable, Identifiable { + let id: UInt32 + let name: String + let pixelWidth: Int + let pixelHeight: Int + let scale: Double + let rotation: Double +} + +struct RemotePairingChallenge: Codable, Equatable, Identifiable { + let id: String + let code: String + let macName: String + let expiresAt: Date +} + +struct TrustedRemoteDevice: Codable, Equatable, Identifiable { + let id: String + var name: String + let publicKeyRawRepresentation: Data + let approvedAt: Date + var revokedAt: Date? +} + +struct RemoteDesktopLease: Codable, Equatable, Identifiable { + let id: String + let deviceId: String + let expiresAt: Date +} + +struct RemoteSessionDescription: Codable, Equatable { + let type: String + let sdp: String +} + +struct RemoteICECandidate: Codable, Equatable { + let candidate: String + let sdpMid: String? + let sdpMLineIndex: Int32? +} + +enum RemoteClipboardDirection: String, Codable { + case send + case receive +} + +struct RemoteClipboardRequest: Codable, Equatable { + let sessionId: String + let direction: RemoteClipboardDirection + let text: String? +} + +struct RemoteAuditEvent: Codable, Equatable, Identifiable { + let id: String + let timestamp: Date + let kind: String + let deviceId: String? + let sessionId: String? +} + +struct RemoteAuditEventResponse: Codable, Equatable { + let events: [RemoteAuditEvent] + let nextCursor: String? +} + +enum RemoteDesktopAuditEventKind: String, Codable { + case pairingChallengeIssued + case pairingChallengeVerified + case pairingApproved + case deviceRevoked + case nonceIssued + case nonceConsumed + case leaseGranted + case leaseRenewed + case leaseEnded + case sequenceAccepted + case sequenceRejected + case controllerBusy + case clipboardRequested + case displayChanged +} + +enum RemoteDesktopAuditReason: String, Codable { + case expired + case used + case invalidSignature + case revoked + case busy + case replay + case unauthorized + case leaseExpired +} + +struct RemoteDesktopAuditEvent: Codable, Equatable, Identifiable { + let id: String + let timestamp: Date + let kind: RemoteDesktopAuditEventKind + let deviceId: String? + let sessionId: String? + let leaseId: String? + let sequence: UInt64? + let reason: RemoteDesktopAuditReason? +} + +struct HostRPCRequest: Codable, Equatable { + let id: UUID + let method: String + let payload: Data +} + +struct HostRPCResponse: Codable, Equatable { + let id: UUID + let status: Int + let payload: Data + let errorCode: String? +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopSocketServer.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopSocketServer.swift new file mode 100644 index 00000000..52defe5d --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopSocketServer.swift @@ -0,0 +1,368 @@ +import Darwin +import Foundation + +final class RemoteDesktopSocketServer { + static let defaultSocketURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codepilot/remote-desktop/host.sock") + + private static let supportedMethods: Set = [ + "status", + "pairing.start", + "pairing.complete", + "devices.list", + "devices.revoke", + "session.nonce", + "session.start", + "session.signal", + "session.end", + "session.display", + "session.clipboard", + "frame.capture", + "input.inject", + "audit.list" + ] + + private enum LineReadResult { + case line(Data) + case oversized + case closed + } + + private let socketURL: URL + private let maxRequestBytes: Int + private let handler: (HostRPCRequest) -> HostRPCResponse + private let stateLock = NSLock() + private var listenFD: Int32 = -1 + private var running = false + + init( + socketURL: URL = RemoteDesktopSocketServer.defaultSocketURL, + maxRequestBytes: Int = 1_048_576, + handler: @escaping (HostRPCRequest) -> HostRPCResponse + ) { + self.socketURL = socketURL + self.maxRequestBytes = max(1, maxRequestBytes) + self.handler = handler + } + + deinit { + stop() + } + + func start() throws { + stateLock.lock() + defer { stateLock.unlock() } + + if running { + return + } + + try prepareSocketDirectory() + try removeStaleSocketIfNeeded() + let fd = try createListener() + listenFD = fd + running = true + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.acceptLoop() + } + } + + func stop() { + stateLock.lock() + let fd = listenFD + listenFD = -1 + let shouldUnlink = running + running = false + stateLock.unlock() + + if fd >= 0 { + close(fd) + } + if shouldUnlink { + unlink(socketURL.path) + } + } + + private func acceptLoop() { + while isRunning { + let clientFD = accept(listenFD, nil, nil) + if clientFD < 0 { + if !isRunning || errno == EBADF || errno == EINVAL { + break + } + if errno == EINTR { + continue + } + continue + } + + configureClientSocket(clientFD) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.handleClient(clientFD) + } + } + } + + private var isRunning: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return running + } + + private func handleClient(_ clientFD: Int32) { + defer { close(clientFD) } + + do { + switch try readRequestLine(from: clientFD) { + case .closed: + return + case .oversized: + try writeResponse(HostRPCResponse( + id: UUID(), + status: 413, + payload: Data(), + errorCode: "request_too_large" + ), to: clientFD) + case .line(let data): + let decoder = JSONDecoder() + do { + let request = try decoder.decode(HostRPCRequest.self, from: data) + guard Self.supportedMethods.contains(request.method) else { + try writeResponse(HostRPCResponse( + id: request.id, + status: 404, + payload: Data(), + errorCode: "unsupported_method" + ), to: clientFD) + return + } + + let response = handler(request) + try writeResponse(HostRPCResponse( + id: request.id, + status: response.status, + payload: response.payload, + errorCode: response.errorCode + ), to: clientFD) + } catch { + try writeResponse(HostRPCResponse( + id: UUID(), + status: 400, + payload: Data(), + errorCode: "invalid_json" + ), to: clientFD) + } + } + } catch { + return + } + } + + private func readRequestLine(from fd: Int32) throws -> LineReadResult { + var buffer = [UInt8](repeating: 0, count: 4096) + var data = Data() + var oversized = false + + while true { + let count = recv(fd, &buffer, buffer.count, 0) + if count < 0 { + if errno == EINTR { + continue + } + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + if count == 0 { + return data.isEmpty ? .closed : (oversized ? .oversized : .line(data)) + } + + for byte in buffer.prefix(count) { + if byte == 0x0A { + return oversized ? .oversized : .line(data) + } + if oversized { + continue + } + if data.count < maxRequestBytes { + data.append(byte) + } else { + oversized = true + } + } + } + } + + private func writeResponse(_ response: HostRPCResponse, to fd: Int32) throws { + let encoded = try JSONEncoder().encode(response) + Data([0x0A]) + try encoded.withUnsafeBytes { buffer in + guard let base = buffer.bindMemory(to: UInt8.self).baseAddress else { + throw POSIXError(.EINVAL) + } + var offset = 0 + while offset < encoded.count { + let written = send(fd, base.advanced(by: offset), encoded.count - offset, 0) + if written < 0 { + if errno == EINTR { + continue + } + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + offset += written + } + } + } + + private func configureClientSocket(_ clientFD: Int32) { + var one: Int32 = 1 + _ = setsockopt( + clientFD, + SOL_SOCKET, + SO_NOSIGPIPE, + &one, + socklen_t(MemoryLayout.size(ofValue: one)) + ) + + var timeout = timeval(tv_sec: 5, tv_usec: 0) + _ = setsockopt( + clientFD, + SOL_SOCKET, + SO_RCVTIMEO, + &timeout, + socklen_t(MemoryLayout.size(ofValue: timeout)) + ) + } + + private func prepareSocketDirectory() throws { + let directoryURL = socketURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: nil + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directoryURL.path + ) + } + + private func removeStaleSocketIfNeeded() throws { + var fileInfo = stat() + let result = lstat(socketURL.path, &fileInfo) + if result != 0 { + return + } + + if (fileInfo.st_mode & S_IFMT) != S_IFSOCK { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTSOCK), userInfo: nil) + } + + switch try probeSocketState() { + case .live: + throw NSError(domain: NSPOSIXErrorDomain, code: Int(EADDRINUSE), userInfo: nil) + case .stale: + if unlink(socketURL.path) != 0 && errno != ENOENT { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + case .ambiguous(let error): + throw error + } + } + + private enum SocketState { + case live + case stale + case ambiguous(Error) + } + + private func probeSocketState() throws -> SocketState { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { close(fd) } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketURL.path.utf8) + guard pathBytes.count < MemoryLayout.size(ofValue: address.sun_path) else { + return .ambiguous(NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG), userInfo: nil)) + } + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + buffer.initializeMemory(as: UInt8.self, repeating: 0) + if let base = buffer.baseAddress { + pathBytes.withUnsafeBytes { source in + if let sourceBase = source.baseAddress { + memcpy(base, sourceBase, pathBytes.count) + } + } + } + } + + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { + return .live + } + + let code = errno + switch code { + case ECONNREFUSED, ENOENT: + return .stale + default: + return .ambiguous(POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO)) + } + } + + private func createListener() throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketURL.path.utf8) + guard pathBytes.count < MemoryLayout.size(ofValue: address.sun_path) else { + close(fd) + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG), userInfo: nil) + } + + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + buffer.initializeMemory(as: UInt8.self, repeating: 0) + if let base = buffer.baseAddress { + pathBytes.withUnsafeBytes { source in + if let sourceBase = source.baseAddress { + memcpy(base, sourceBase, pathBytes.count) + } + } + } + } + + let bindResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + let code = errno + close(fd) + throw POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO) + } + + guard Darwin.listen(fd, 4) == 0 else { + let code = errno + close(fd) + throw POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO) + } + + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: socketURL.path + ) + + return fd + } +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift new file mode 100644 index 00000000..076fca40 --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift @@ -0,0 +1,176 @@ +import AppKit +import Foundation + +final class RemoteDesktopWindowController: NSWindowController { + private let coordinator: RemoteDesktopCoordinator + private let contentStack = NSStackView() + + init(coordinator: RemoteDesktopCoordinator) { + self.coordinator = coordinator + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 620), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "CodePilot Remote Desktop" + super.init(window: window) + buildContent() + refresh() + } + + required init?(coder: NSCoder) { + nil + } + + override func showWindow(_ sender: Any?) { + coordinator.refreshStatus() + refresh() + super.showWindow(sender) + } + + private func buildContent() { + guard let window else { return } + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + + contentStack.orientation = .vertical + contentStack.alignment = .leading + contentStack.spacing = 12 + contentStack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18) + contentStack.translatesAutoresizingMaskIntoConstraints = false + + scrollView.documentView = contentStack + window.contentView = scrollView + + NSLayoutConstraint.activate([ + contentStack.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor, constant: -36) + ]) + } + + private func refresh() { + contentStack.arrangedSubviews.forEach { view in + contentStack.removeArrangedSubview(view) + view.removeFromSuperview() + } + + let snapshot = coordinator.snapshot + addSection("Permissions", rows: [ + "Screen Recording: \(snapshot.screenRecordingGranted ? "Granted" : "Missing")", + "Accessibility: \(snapshot.accessibilityGranted ? "Granted" : "Missing")", + "Mac unlocked: \(snapshot.macUnlocked ? "Yes" : "No")" + ]) + + if let pending = snapshot.pendingPairing { + addSection("Pending Pairing", rows: [ + "Device: \(pending.name)", + "Code: \(pending.challenge.code)", + "Expires: \(Self.dateFormatter.string(from: pending.challenge.expiresAt))", + "Key: \(pending.keyFingerprint)" + ]) + addButtonRow([ + ("Approve", #selector(approvePendingPairing)), + ("Reject", #selector(rejectPendingPairing)) + ]) + } else { + addSection("Pending Pairing", rows: ["None"]) + } + + let trustedRows = snapshot.trustedDevices.isEmpty + ? ["No trusted devices"] + : snapshot.trustedDevices.map { device in + let status = device.revokedAt == nil ? "Trusted" : "Revoked" + return "\(device.name) (\(device.id)) - \(status)" + } + addSection("Trusted Devices", rows: trustedRows) + addTrustedDeviceActions(snapshot.trustedDevices) + + if let active = snapshot.activeSession { + addSection("Active Controller", rows: [ + "Device: \(active.deviceID)", + "Lease: \(active.leaseID)", + "Started: \(Self.dateFormatter.string(from: active.startedAt))" + ]) + addButtonRow([("Emergency Disconnect", #selector(emergencyDisconnect))]) + } else { + addSection("Active Controller", rows: ["None"]) + } + + let auditRows = snapshot.auditEvents.suffix(12).map { event in + "\(Self.dateFormatter.string(from: event.timestamp)) \(event.kind.rawValue)" + } + addSection("Audit", rows: auditRows.isEmpty ? ["No audit events"] : auditRows) + } + + private func addSection(_ title: String, rows: [String]) { + let titleLabel = NSTextField(labelWithString: title) + titleLabel.font = .boldSystemFont(ofSize: 13) + contentStack.addArrangedSubview(titleLabel) + + for row in rows { + let label = NSTextField(labelWithString: row) + label.font = .systemFont(ofSize: 12) + label.textColor = .secondaryLabelColor + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 0 + contentStack.addArrangedSubview(label) + } + } + + private func addButtonRow(_ buttons: [(String, Selector)]) { + let row = NSStackView() + row.orientation = .horizontal + row.spacing = 8 + for (title, action) in buttons { + let button = NSButton(title: title, target: self, action: action) + button.bezelStyle = .rounded + row.addArrangedSubview(button) + } + contentStack.addArrangedSubview(row) + } + + private func addTrustedDeviceActions(_ devices: [TrustedRemoteDevice]) { + let activeDevices = devices.filter { $0.revokedAt == nil } + guard !activeDevices.isEmpty else { return } + + let row = NSStackView() + row.orientation = .horizontal + row.spacing = 8 + for device in activeDevices { + let button = NSButton(title: "Revoke \(device.name)", target: self, action: #selector(revokeDevice(_:))) + button.bezelStyle = .rounded + button.identifier = NSUserInterfaceItemIdentifier(device.id) + row.addArrangedSubview(button) + } + contentStack.addArrangedSubview(row) + } + + @objc private func approvePendingPairing() { + try? coordinator.approvePendingPairing() + refresh() + } + + @objc private func rejectPendingPairing() { + coordinator.rejectPendingPairing() + refresh() + } + + @objc private func emergencyDisconnect() { + coordinator.emergencyDisconnect() + refresh() + } + + @objc private func revokeDevice(_ sender: NSButton) { + guard let id = sender.identifier?.rawValue else { return } + try? coordinator.revokeDevice(id: id) + refresh() + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + return formatter + }() +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteFrameCaptureService.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteFrameCaptureService.swift new file mode 100644 index 00000000..1237be77 --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteFrameCaptureService.swift @@ -0,0 +1,43 @@ +import AppKit +import CoreGraphics +import Foundation + +final class RemoteFrameCaptureService { + enum CaptureError: Error { + case unavailable + case encodingFailed + } + + func captureMainDisplayJPEG(maxPixelWidth: Int = 1600, quality: CGFloat = 0.62) throws -> Data { + guard let image = CGDisplayCreateImage(CGMainDisplayID()) else { + throw CaptureError.unavailable + } + + let sourceSize = CGSize(width: image.width, height: image.height) + let outputSize: CGSize + if sourceSize.width > CGFloat(maxPixelWidth) { + let scale = CGFloat(maxPixelWidth) / sourceSize.width + outputSize = CGSize(width: sourceSize.width * scale, height: sourceSize.height * scale) + } else { + outputSize = sourceSize + } + return try jpegData(from: image, size: outputSize, quality: quality) + } + + private func jpegData(from image: CGImage, size: CGSize, quality: CGFloat) throws -> Data { + let output = NSImage(size: size) + output.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .medium + NSImage(cgImage: image, size: CGSize(width: image.width, height: image.height)) + .draw(in: NSRect(origin: .zero, size: size)) + output.unlockFocus() + + guard let tiff = output.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let data = bitmap.representation(using: .jpeg, properties: [.compressionFactor: quality]) else { + throw CaptureError.encodingFailed + } + return data + } + +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/ScreenCaptureService.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/ScreenCaptureService.swift new file mode 100644 index 00000000..8423db91 --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/ScreenCaptureService.swift @@ -0,0 +1,89 @@ +import CoreGraphics +import CoreMedia +import Foundation +import ScreenCaptureKit + +protocol ScreenCaptureServicing: AnyObject { + func displays() async throws -> [RemoteDisplay] + func start(displayID: CGDirectDisplayID, frameHandler: @escaping (CMSampleBuffer) -> Void) async throws + func stop() +} + +final class ScreenCaptureService: NSObject, ScreenCaptureServicing, SCStreamOutput, SCStreamDelegate { + private var activeDisplayID: CGDirectDisplayID? + private var stream: SCStream? + private var frameHandler: ((CMSampleBuffer) -> Void)? + private let captureQueue = DispatchQueue(label: "io.codepilot.remote-desktop.capture", qos: .userInteractive) + + @available(macOS 12.3, *) + func displays() async throws -> [RemoteDisplay] { + let content = try await SCShareableContent.current + return content.displays.map { display in + RemoteDisplay( + id: display.displayID, + name: display.displayID == CGMainDisplayID() ? "Main Display" : "Display \(display.displayID)", + pixelWidth: display.width, + pixelHeight: display.height, + scale: 1, + rotation: 0 + ) + } + } + + func selectDisplay(_ displayID: CGDirectDisplayID) { + activeDisplayID = displayID + } + + @available(macOS 12.3, *) + func start( + displayID: CGDirectDisplayID = CGMainDisplayID(), + frameHandler: @escaping (CMSampleBuffer) -> Void + ) async throws { + stop() + let content = try await SCShareableContent.current + guard let display = content.displays.first(where: { $0.displayID == displayID }) else { + throw ScreenCaptureError.displayUnavailable + } + + let scale = min(1, 1920.0 / Double(display.width)) + let configuration = SCStreamConfiguration() + configuration.width = max(2, Int(Double(display.width) * scale) & ~1) + configuration.height = max(2, Int(Double(display.height) * scale) & ~1) + configuration.minimumFrameInterval = CMTime(value: 1, timescale: 30) + configuration.queueDepth = 5 + configuration.pixelFormat = kCVPixelFormatType_32BGRA + configuration.showsCursor = true + configuration.capturesAudio = false + + let filter = SCContentFilter(display: display, excludingWindows: []) + let stream = SCStream(filter: filter, configuration: configuration, delegate: self) + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: captureQueue) + self.activeDisplayID = displayID + self.frameHandler = frameHandler + self.stream = stream + try await stream.startCapture() + } + + func stop() { + let stream = self.stream + self.stream = nil + frameHandler = nil + activeDisplayID = nil + if let stream { + Task { try? await stream.stopCapture() } + } + } + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .screen, sampleBuffer.isValid, CMSampleBufferDataIsReady(sampleBuffer) else { return } + frameHandler?(sampleBuffer) + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + stop() + } +} + +enum ScreenCaptureError: Error { + case displayUnavailable +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/SessionLeaseStore.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/SessionLeaseStore.swift new file mode 100644 index 00000000..58a8b1d1 --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/SessionLeaseStore.swift @@ -0,0 +1,279 @@ +import Foundation + +final class SessionLeaseStore { + typealias Clock = () -> Date + typealias CurrentTrustValidator = (String) throws -> Void + + private struct NonceRecord: Equatable { + let nonce: String + let deviceID: String + let issuedAt: Date + let expiresAt: Date + var usedAt: Date? + } + + private struct LeaseRecord: Equatable { + var lease: RemoteDesktopLease + let issuedAt: Date + var expiresAt: Date + var revokedAt: Date? + var endedAt: Date? + var lastAcceptedSequence: UInt64? + + var isActive: Bool { + revokedAt == nil && endedAt == nil + } + } + + private let clock: Clock + private let currentTrustValidator: CurrentTrustValidator + private let lock = NSLock() + private var noncesByValue: [String: NonceRecord] = [:] + private var leasesByID: [String: LeaseRecord] = [:] + private var activeLeaseID: String? + + init( + clock: @escaping Clock = Date.init, + currentTrustValidator: @escaping CurrentTrustValidator + ) { + self.clock = clock + self.currentTrustValidator = currentTrustValidator + } + + func issueNonce(for deviceID: String) -> String { + lock.lock() + defer { lock.unlock() } + + purgeExpiredState() + + let nonce = Self.randomToken() + let now = clock() + noncesByValue[nonce] = NonceRecord( + nonce: nonce, + deviceID: deviceID, + issuedAt: now, + expiresAt: now.addingTimeInterval(Self.nonceTTL), + usedAt: nil + ) + return nonce + } + + func createLease(using proof: VerifiedRemoteDesktopNonce) throws -> RemoteDesktopLease { + lock.lock() + defer { lock.unlock() } + + purgeExpiredState() + do { + try currentTrustValidator(proof.deviceID) + } catch { + invalidateActiveLeaseIfNeeded(for: proof.deviceID) + throw error + } + try consume(proof: proof) + guard activeLeaseID == nil else { + throw RemoteDesktopSecurityError.controllerBusy + } + + let now = clock() + let lease = RemoteDesktopLease( + id: UUID().uuidString, + deviceId: proof.deviceID, + expiresAt: now.addingTimeInterval(Self.leaseTTL) + ) + leasesByID[lease.id] = LeaseRecord( + lease: lease, + issuedAt: now, + expiresAt: lease.expiresAt, + revokedAt: nil, + endedAt: nil, + lastAcceptedSequence: nil + ) + activeLeaseID = lease.id + return lease + } + + func renewLease(leaseID: String, using proof: VerifiedRemoteDesktopNonce) throws -> RemoteDesktopLease { + lock.lock() + defer { lock.unlock() } + + purgeExpiredState() + do { + try currentTrustValidator(proof.deviceID) + } catch { + invalidateActiveLeaseIfNeeded(for: proof.deviceID) + throw error + } + guard var record = leasesByID[leaseID] else { + throw RemoteDesktopSecurityError.leaseUnknown + } + guard record.lease.deviceId == proof.deviceID else { + throw RemoteDesktopSecurityError.leaseUnknown + } + guard record.isActive else { + throw RemoteDesktopSecurityError.leaseExpired + } + guard activeLeaseID == leaseID else { + throw RemoteDesktopSecurityError.leaseUnknown + } + + try consume(proof: proof) + + let now = clock() + record.lease = RemoteDesktopLease( + id: record.lease.id, + deviceId: record.lease.deviceId, + expiresAt: now.addingTimeInterval(Self.leaseTTL) + ) + record.expiresAt = record.lease.expiresAt + leasesByID[leaseID] = record + return record.lease + } + + func validateSequence(_ sequence: UInt64, for leaseID: String) throws { + lock.lock() + defer { lock.unlock() } + + purgeExpiredState() + guard var record = leasesByID[leaseID] else { + throw RemoteDesktopSecurityError.leaseUnknown + } + do { + try currentTrustValidator(record.lease.deviceId) + } catch { + invalidateActiveLeaseIfNeeded(for: record.lease.deviceId) + throw error + } + guard record.isActive else { + throw RemoteDesktopSecurityError.leaseExpired + } + if let lastAcceptedSequence = record.lastAcceptedSequence, sequence <= lastAcceptedSequence { + throw RemoteDesktopSecurityError.sequenceReplay + } + record.lastAcceptedSequence = sequence + leasesByID[leaseID] = record + } + + func endLease(leaseID: String) { + lock.lock() + defer { lock.unlock() } + + guard var record = leasesByID[leaseID] else { + return + } + let now = clock() + record.endedAt = now + record.expiresAt = now + leasesByID[leaseID] = record + if activeLeaseID == leaseID { + activeLeaseID = nil + } + } + + func revokeLease(leaseID: String) { + lock.lock() + defer { lock.unlock() } + + guard var record = leasesByID[leaseID] else { + return + } + let now = clock() + record.revokedAt = now + record.expiresAt = now + leasesByID[leaseID] = record + if activeLeaseID == leaseID { + activeLeaseID = nil + } + } + + func activeLease() -> RemoteDesktopLease? { + lock.lock() + defer { lock.unlock() } + + purgeExpiredState() + guard let activeLeaseID, + let record = leasesByID[activeLeaseID], + record.isActive else { + return nil + } + do { + try currentTrustValidator(record.lease.deviceId) + } catch { + invalidateLeaseLocked(leaseID: activeLeaseID) + return nil + } + return record.lease + } + + private static let nonceTTL: TimeInterval = 60 + private static let leaseTTL: TimeInterval = 10 * 60 + + private static func randomToken() -> String { + var generator = SystemRandomNumberGenerator() + let bytes = (0..<32).map { _ in UInt8.random(in: .min ... .max, using: &generator) } + return Data(bytes).base64EncodedString() + } + + private func consume(proof: VerifiedRemoteDesktopNonce) throws { + let now = clock() + guard now <= proof.expiresAt else { + throw RemoteDesktopSecurityError.nonceExpired + } + guard var record = noncesByValue[proof.nonce] else { + throw RemoteDesktopSecurityError.nonceUnknown + } + guard record.deviceID == proof.deviceID else { + throw RemoteDesktopSecurityError.nonceUnknown + } + guard proof.verifiedAt >= record.issuedAt else { + throw RemoteDesktopSecurityError.nonceUnknown + } + guard now <= record.expiresAt else { + throw RemoteDesktopSecurityError.nonceExpired + } + guard record.usedAt == nil else { + throw RemoteDesktopSecurityError.nonceAlreadyUsed + } + record.usedAt = now + noncesByValue[proof.nonce] = record + } + + private func purgeExpiredState() { + let now = clock() + + noncesByValue = noncesByValue.filter { $0.value.expiresAt > now } + + guard let activeLeaseID, let record = leasesByID[activeLeaseID] else { + return + } + if record.isActive && record.expiresAt <= now { + var expiredRecord = record + expiredRecord.expiresAt = now + expiredRecord.endedAt = now + leasesByID[activeLeaseID] = expiredRecord + self.activeLeaseID = nil + } + } + + private func invalidateActiveLeaseIfNeeded(for deviceID: String) { + guard let activeLeaseID, + let record = leasesByID[activeLeaseID], + record.lease.deviceId == deviceID else { + return + } + invalidateLeaseLocked(leaseID: activeLeaseID) + } + + private func invalidateLeaseLocked(leaseID: String) { + guard var record = leasesByID[leaseID] else { + return + } + let now = clock() + record.revokedAt = now + record.endedAt = now + record.expiresAt = now + leasesByID[leaseID] = record + if activeLeaseID == leaseID { + activeLeaseID = nil + } + } +} diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/WebRTCFrameAdapter.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/WebRTCFrameAdapter.swift new file mode 100644 index 00000000..35ef82ca --- /dev/null +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/WebRTCFrameAdapter.swift @@ -0,0 +1,22 @@ +import CoreMedia +import Foundation +import LiveKitWebRTC + +final class WebRTCFrameAdapter { + private let capturer: LKRTCVideoCapturer + private weak var delegate: LKRTCVideoCapturerDelegate? + + init(delegate: LKRTCVideoCapturerDelegate) { + self.delegate = delegate + self.capturer = LKRTCVideoCapturer(delegate: delegate) + } + + func consume(_ sampleBuffer: CMSampleBuffer) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + let buffer = LKRTCCVPixelBuffer(pixelBuffer: pixelBuffer) + let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + let timeStampNs = Int64(CMTimeGetSeconds(timestamp) * 1_000_000_000) + let frame = LKRTCVideoFrame(buffer: buffer, rotation: ._0, timeStampNs: timeStampNs) + delegate?.capturer(capturer, didCapture: frame) + } +} diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index a89bd6d2..cdf1059f 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -669,6 +669,10 @@ final class CodexAccountSwitcher { guard !events.isEmpty else { return } let active = activeAccountName() applyTurnTracking(from: events) + if events.contains(where: { $0.isTerminalTurnActivity || $0.clearsThreadTurns }), + activeTurnTracker.activeTurnCount == 0 { + persistActiveAuthToProfile(context: "after turn finished") + } if pendingSwitchReason == nil, let exhausted = events.first(where: { $0.isTokenExhaustion }) { pendingSwitchReason = exhausted.summary @@ -680,6 +684,22 @@ final class CodexAccountSwitcher { } } + private func persistActiveAuthToProfile(context: String) { + do { + let didSync = try AuthProfileSynchronizer.syncActiveAuthToProfile( + activeAuth: settings.activeAuth, + activeAccountMarker: settings.activeAccountMarker, + accountsDir: settings.accountsDir, + fileManager: fileManager + ) + if didSync { + appendAudit("Synced active auth to profile \(context)") + } + } catch { + appendAudit("Skipped active auth sync \(context): \(error.localizedDescription)") + } + } + private func processPendingSwitchIfNeeded() { updatePendingSwitchFromUsage() @@ -1246,6 +1266,7 @@ final class CodexAccountSwitcher { throw SwitchError.activeTurnsRunning(blockers.count) } + persistActiveAuthToProfile(context: "before switching to \(next.name)") try backupActiveAuth() let temporary = settings.activeAuth.deletingLastPathComponent() .appendingPathComponent(".auth.json.codex-account-switcher.tmp") @@ -1481,6 +1502,115 @@ struct CodexQuitGuard { } } +enum AuthProfileSynchronizer { + @discardableResult + static func syncActiveAuthToProfile( + activeAuth: URL, + activeAccountMarker: URL, + accountsDir: URL, + fileManager: FileManager = .default + ) throws -> Bool { + guard fileManager.fileExists(atPath: activeAuth.path) else { return false } + guard let accountName = try? String(contentsOf: activeAccountMarker, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + !accountName.isEmpty, + accountName != "unknown" else { + return false + } + + let profileAuth = accountsDir + .appendingPathComponent(accountName, isDirectory: true) + .appendingPathComponent("auth.json") + guard fileManager.fileExists(atPath: profileAuth.path) else { return false } + + return try withAuthFileLock(near: activeAccountMarker, fileManager: fileManager) { + let activeData = try Data(contentsOf: activeAuth) + let profileData = try Data(contentsOf: profileAuth) + guard activeData != profileData else { return false } + + let decoder = JSONDecoder() + let activeSnapshot = try decoder.decode(AuthFileSnapshot.self, from: activeData) + let profileSnapshot = try decoder.decode(AuthFileSnapshot.self, from: profileData) + let activeAccountID = activeSnapshot.tokens?.accountID ?? "" + let profileAccountID = profileSnapshot.tokens?.accountID ?? "" + if !activeAccountID.isEmpty, !profileAccountID.isEmpty, activeAccountID != profileAccountID { + return false + } + + if let activeRefresh = activeSnapshot.lastRefreshDate, + let profileRefresh = profileSnapshot.lastRefreshDate, + activeRefresh < profileRefresh { + return false + } + + try installAuth(from: activeAuth, to: profileAuth, fileManager: fileManager) + return true + } + } + + private static func withAuthFileLock( + near marker: URL, + fileManager: FileManager, + body: () throws -> T + ) throws -> T { + let lockURL = marker.deletingLastPathComponent().appendingPathComponent("auth-files.lock") + fileManager.createFile(atPath: lockURL.path, contents: nil) + let fd = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard fd >= 0 else { throw POSIXError(.EIO) } + defer { close(fd) } + guard flock(fd, LOCK_EX) == 0 else { throw POSIXError(.EIO) } + defer { flock(fd, LOCK_UN) } + return try body() + } + + private static func installAuth( + from source: URL, + to destination: URL, + fileManager: FileManager + ) throws { + try fileManager.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let temporary = destination + .deletingLastPathComponent() + .appendingPathComponent(".auth.json.codex-account-switcher.tmp") + if fileManager.fileExists(atPath: temporary.path) { + try fileManager.removeItem(at: temporary) + } + try fileManager.copyItem(at: source, to: temporary) + if fileManager.fileExists(atPath: destination.path) { + _ = try fileManager.replaceItemAt(destination, withItemAt: temporary) + } else { + try fileManager.moveItem(at: temporary, to: destination) + } + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) + } +} + +private struct AuthFileSnapshot: Decodable { + let lastRefresh: String? + let tokens: AuthFileSnapshotTokens? + + var lastRefreshDate: Date? { + guard let lastRefresh else { return nil } + return ISO8601DateFormatter().date(from: lastRefresh) + } + + enum CodingKeys: String, CodingKey { + case lastRefresh = "last_refresh" + case tokens + } +} + +private struct AuthFileSnapshotTokens: Decodable { + let accountID: String? + + enum CodingKeys: String, CodingKey { + case accountID = "account_id" + } +} + private struct PhoneGatewayActiveJobsResponse: Decodable { let job: PhoneGatewayJob? let jobs: [PhoneGatewayJob]? @@ -2184,12 +2314,7 @@ enum SwitchError: LocalizedError { } private struct CodePilotSetupStatus { - let codexCLI: String - let codexAuth: String - let accounts: String - let gatewayToken: String - let gateway: String - let cloudflared: String + let rows: [CodePilotSetupRow] static func load() -> CodePilotSetupStatus { let home = FileManager.default.homeDirectoryForCurrentUser @@ -2203,14 +2328,52 @@ private struct CodePilotSetupStatus { options: [.skipsHiddenFiles] ).filter { FileManager.default.fileExists(atPath: $0.appendingPathComponent("auth.json").path) }.count) ?? 0 - return CodePilotSetupStatus( - codexCLI: executablePath(named: "codex") ?? "Not found", - codexAuth: FileManager.default.fileExists(atPath: codexAuth) ? "Signed in" : "Missing auth.json", - accounts: accountCount == 1 ? "1 profile" : "\(accountCount) profiles", - gatewayToken: FileManager.default.fileExists(atPath: tokenPath.path) ? "Token present" : "Missing token", - gateway: gatewayHealth(), - cloudflared: executablePath(named: "cloudflared") ?? "Not found" - ) + let codexCLI = executablePath(named: "codex") + let cloudflared = executablePath(named: "cloudflared") + let cloudflareRequirement: CodePilotSetupRequirement + let cloudflareDetail: String + if cloudflared == nil { + cloudflareRequirement = .cloudflareMissing + cloudflareDetail = "Install cloudflared to enable remote iPhone access." + } else if cloudflareMetadataExists() || cloudflareConfigExists() { + cloudflareRequirement = .cloudflareReady + cloudflareDetail = cloudflareReadyDetail(defaultPath: cloudflared) + } else { + cloudflareRequirement = .cloudflareNeedsConfiguration + cloudflareDetail = "cloudflared is installed; set up a tunnel for remote access." + } + return CodePilotSetupStatus(rows: [ + CodePilotSetupRow( + title: "Codex CLI", + requirement: codexCLI == nil ? .codexCLIMissing : .codexCLIInstalled, + detail: codexCLI ?? "Install Codex before using CodePilot." + ), + CodePilotSetupRow( + title: "Codex Login", + requirement: FileManager.default.fileExists(atPath: codexAuth) ? .codexSignedIn : .codexSignedOut, + detail: FileManager.default.fileExists(atPath: codexAuth) ? "Signed in" : "Missing auth.json" + ), + CodePilotSetupRow( + title: "Account Profiles", + requirement: accountCount > 0 ? .profilesCreated : .profilesMissing, + detail: accountCount == 1 ? "1 profile" : "\(accountCount) profiles" + ), + CodePilotSetupRow( + title: "Gateway Token", + requirement: FileManager.default.fileExists(atPath: tokenPath.path) ? .gatewayTokenPresent : .gatewayTokenMissing, + detail: FileManager.default.fileExists(atPath: tokenPath.path) ? "Token present" : "Missing token" + ), + CodePilotSetupRow( + title: "Gateway", + requirement: gatewayHealthRequirement(), + detail: gatewayHealthDetail() + ), + CodePilotSetupRow( + title: "Cloudflare", + requirement: cloudflareRequirement, + detail: cloudflareDetail + ) + ]) } private static func executablePath(named name: String) -> String? { @@ -2231,19 +2394,144 @@ private struct CodePilotSetupStatus { return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) } - private static func gatewayHealth() -> String { - guard let url = URL(string: "http://127.0.0.1:18790/api/health"), - let data = try? Data(contentsOf: url), - !data.isEmpty else { - return "Not reachable" + private static func gatewayHealthRequirement() -> CodePilotSetupRequirement { + let tokenPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex-account-switcher/phone-gateway-token") + guard let token = try? String(contentsOf: tokenPath, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty, + let url = URL(string: "http://127.0.0.1:18790/api/health") else { + return .gatewayStopped + } + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + guard let data = synchronousData(for: request), !data.isEmpty else { + return .gatewayStopped + } + return .gatewayRunning + } + + private static func synchronousData(for request: URLRequest) -> Data? { + let semaphore = DispatchSemaphore(value: 0) + var result: Data? + let task = URLSession.shared.dataTask(with: request) { data, _, _ in + result = data + semaphore.signal() + } + task.resume() + _ = semaphore.wait(timeout: .now() + 2) + return result + } + + private static func gatewayHealthDetail() -> String { + gatewayHealthRequirement() == .gatewayRunning + ? "Reachable on 127.0.0.1:18790" + : "Not reachable on 127.0.0.1:18790" + } + + private static func cloudflareMetadataExists() -> Bool { + let path = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex-account-switcher/cloudflare-setup.json") + return FileManager.default.fileExists(atPath: path.path) + } + + private static func cloudflareConfigExists() -> Bool { + let home = FileManager.default.homeDirectoryForCurrentUser + let modern = home.appendingPathComponent(".cloudflared/codepilot-config.yaml") + let legacy = home.appendingPathComponent(".cloudflared/codex-phone-config.yaml") + return FileManager.default.fileExists(atPath: modern.path) || FileManager.default.fileExists(atPath: legacy.path) + } + + private static func cloudflareReadyDetail(defaultPath: String?) -> String { + if let metadata = loadCloudflareMetadata(), !metadata.hostname.isEmpty { + return metadata.hostname + } + return defaultPath ?? "Configured" + } + + private static func loadCloudflareMetadata() -> CodePilotCloudflareMetadata? { + let path = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex-account-switcher/cloudflare-setup.json") + guard let data = try? Data(contentsOf: path) else { return nil } + return try? JSONDecoder().decode(CodePilotCloudflareMetadata.self, from: data) + } +} + +struct CodePilotSetupRow: Equatable { + let title: String + let requirement: CodePilotSetupRequirement + let detail: String +} + +struct CodePilotCloudflareMetadata: Codable, Equatable { + let mode: String + let hostname: String + let tunnelName: String + let tunnelId: String + let configPath: String + let launchAgentLabel: String + let lastVerifiedAt: String? + + var safeSummary: String { + let host = hostname.isEmpty ? "No hostname configured" : hostname + return "\(mode) tunnel \(tunnelName) for \(host)" + } +} + +enum CodePilotCloudflareErrorMapper { + static func message(forExitCode code: Int32) -> String { + switch code { + case 20: + return "Homebrew is missing. Install Homebrew or use Cloudflare's manual cloudflared installer, then retry." + case 21: + return "cloudflared is missing. Install it from the Cloudflare setup step before continuing." + default: + return "Cloudflare setup did not finish. Open details, review the last command output, and retry the failed step." + } + } +} + +enum CodePilotSetupRequirement: Equatable { + case codexCLIInstalled + case codexCLIMissing + case codexSignedIn + case codexSignedOut + case profilesCreated + case profilesMissing + case gatewayTokenPresent + case gatewayTokenMissing + case gatewayRunning + case gatewayStopped + case gatewayBlockedByActiveTurn + case cloudflareReady + case cloudflareOptional + case cloudflareMissing + case cloudflareNeedsConfiguration + case screenRecordingMissing + case accessibilityMissing + case notificationsOptional + + var statusLabel: String { + switch self { + case .codexCLIInstalled, .codexSignedIn, .profilesCreated, .gatewayTokenPresent, .gatewayRunning, .cloudflareReady: + return "Ready" + case .cloudflareNeedsConfiguration: + return "Needs setup" + case .gatewayStopped: + return "Stopped" + case .gatewayBlockedByActiveTurn: + return "Blocked by active turn" + case .cloudflareOptional, .notificationsOptional: + return "Optional" + case .codexCLIMissing, .codexSignedOut, .profilesMissing, .gatewayTokenMissing, .cloudflareMissing, .screenRecordingMissing, .accessibilityMissing: + return "Missing" } - return "Reachable" } } private final class CodePilotSetupWindowController: NSWindowController { private let statusStack = NSStackView() private let outputLabel = NSTextField(labelWithString: "") + private var cloudflareWizardController: CodePilotCloudflareWizardController? convenience init() { let window = NSWindow( @@ -2297,14 +2585,16 @@ private final class CodePilotSetupWindowController: NSWindowController { root.addArrangedSubview(section( title: "Gateway", buttons: [ - button("Install / Restart Gateway", #selector(installGateway)), + button("Restart Gateway When Idle", #selector(restartGatewayWhenIdle)), + button("Force Restart Gateway...", #selector(forceRestartGateway)), button("Copy iOS Token", #selector(copyToken)) ] )) root.addArrangedSubview(section( - title: "Cloudflare", + title: "Cloudflare Remote Access", buttons: [ - button("Install Cloudflare Service", #selector(installCloudflare)), + button("Set Up Remote Access...", #selector(openCloudflareWizard)), + button("Restart Tunnel", #selector(restartCloudflareTunnel)), button("Open Cloudflare Guide", #selector(openCloudflareGuide)) ] )) @@ -2344,15 +2634,8 @@ private final class CodePilotSetupWindowController: NSWindowController { private func refreshStatus() { statusStack.arrangedSubviews.forEach { $0.removeFromSuperview() } let status = CodePilotSetupStatus.load() - [ - ("Codex CLI", status.codexCLI), - ("Codex Auth", status.codexAuth), - ("Accounts", status.accounts), - ("Gateway Token", status.gatewayToken), - ("Gateway", status.gateway), - ("Cloudflare", status.cloudflared) - ].forEach { label, value in - let row = NSTextField(labelWithString: "\(label): \(value)") + status.rows.forEach { status in + let row = NSTextField(labelWithString: "\(status.title): \(status.requirement.statusLabel) - \(status.detail)") row.font = .monospacedSystemFont(ofSize: 12, weight: .regular) statusStack.addArrangedSubview(row) } @@ -2372,12 +2655,32 @@ private final class CodePilotSetupWindowController: NSWindowController { NSWorkspace.shared.open(url) } - @objc private func installGateway() { + @objc private func restartGatewayWhenIdle() { runBundledScript(named: "install-phone-gateway-agent.sh") } - @objc private func installCloudflare() { - runBundledScript(named: "install-phone-cloudflared-agent.sh") + @objc private func forceRestartGateway() { + let alert = NSAlert() + alert.messageText = "Force Restart Gateway?" + alert.informativeText = "This can interrupt active phone turns. Use Restart Gateway When Idle unless you need to recover a stuck gateway." + alert.alertStyle = .warning + alert.addButton(withTitle: "Force Restart") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + runBundledScript(named: "install-phone-gateway-agent.sh", force: true) + } + + @objc private func openCloudflareWizard() { + let controller = CodePilotCloudflareWizardController() + cloudflareWizardController = controller + window?.beginSheet(controller.window!) { [weak self] _ in + self?.cloudflareWizardController = nil + self?.refreshStatus() + } + } + + @objc private func restartCloudflareTunnel() { + runBundledScript(named: "setup-cloudflare-remote-access.sh", arguments: ["restart-service"]) } @objc private func copyToken() { @@ -2403,7 +2706,7 @@ private final class CodePilotSetupWindowController: NSWindowController { } } - private func runBundledScript(named name: String) { + private func runBundledScript(named name: String, arguments: [String] = [], force: Bool = false) { let candidates = [ Bundle.main.resourceURL?.appendingPathComponent("scripts/\(name)"), URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("scripts/\(name)") @@ -2415,7 +2718,7 @@ private final class CodePilotSetupWindowController: NSWindowController { let process = Process() let pipe = Pipe() process.executableURL = URL(fileURLWithPath: "/bin/zsh") - process.arguments = [script.path, "--force"] + process.arguments = [script.path] + arguments + (force ? ["--force"] : []) process.standardOutput = pipe process.standardError = pipe process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) @@ -2453,19 +2756,308 @@ private final class CodePilotSetupWindowController: NSWindowController { } } +private final class CodePilotCloudflareWizardController: NSWindowController { + private let outputLabel = NSTextField(wrappingLabelWithString: "") + private let hostnameField = NSTextField(string: "") + private let tunnelNameField = NSTextField(string: "codepilot") + private let detailsLabel = NSTextField(wrappingLabelWithString: "") + + convenience init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 680, height: 560), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Set Up Cloudflare Remote Access" + self.init(window: window) + buildUI() + } + + private func buildUI() { + guard let contentView = window?.contentView else { return } + let root = NSStackView() + root.orientation = .vertical + root.alignment = .leading + root.spacing = 14 + root.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(root) + + NSLayoutConstraint.activate([ + root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + root.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + root.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -20) + ]) + + let title = NSTextField(labelWithString: "Cloudflare Remote Access") + title.font = .systemFont(ofSize: 22, weight: .semibold) + root.addArrangedSubview(title) + + let intro = NSTextField(wrappingLabelWithString: """ + CodePilot can use Cloudflare Tunnel so your iPhone can reach the Mac gateway away from your local network. Setup may install cloudflared, sign in to Cloudflare, create a tunnel, add a DNS route, write ~/.cloudflared/codepilot-config.yaml, and install a LaunchAgent to keep the tunnel running. No inbound ports are opened; the iOS app still needs the gateway token. + """) + intro.textColor = .secondaryLabelColor + root.addArrangedSubview(intro) + + let fields = NSGridView(views: [ + [NSTextField(labelWithString: "Hostname"), hostnameField], + [NSTextField(labelWithString: "Tunnel name"), tunnelNameField] + ]) + fields.column(at: 0).xPlacement = .trailing + fields.column(at: 1).width = 420 + hostnameField.placeholderString = "codepilot.example.com" + root.addArrangedSubview(fields) + + root.addArrangedSubview(buttonRow([ + button("Install cloudflared", #selector(installCloudflared)), + button("Sign In or Create Account", #selector(loginCloudflare)) + ])) + root.addArrangedSubview(buttonRow([ + button("Configure Permanent Hostname", #selector(configurePermanent)), + button("Start Temporary Test URL", #selector(startTemporary)) + ])) + root.addArrangedSubview(buttonRow([ + button("Open Cloudflare Dashboard", #selector(openCloudflareDashboard)), + button("Close", #selector(closeSheet)) + ])) + + outputLabel.textColor = .labelColor + outputLabel.stringValue = "Choose a setup step." + root.addArrangedSubview(outputLabel) + + detailsLabel.textColor = .secondaryLabelColor + detailsLabel.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + detailsLabel.maximumNumberOfLines = 8 + detailsLabel.stringValue = "Details will appear here after a step runs." + root.addArrangedSubview(detailsLabel) + } + + private func buttonRow(_ buttons: [NSButton]) -> NSView { + let row = NSStackView() + row.orientation = .horizontal + row.spacing = 8 + buttons.forEach { row.addArrangedSubview($0) } + return row + } + + private func button(_ title: String, _ action: Selector) -> NSButton { + let button = NSButton(title: title, target: self, action: action) + button.bezelStyle = .rounded + return button + } + + @objc private func installCloudflared() { + runCloudflareStep(["install-cloudflared"], successMessage: "cloudflared is installed.") + } + + @objc private func loginCloudflare() { + guard let script = scriptURL() else { + outputLabel.stringValue = "Could not find setup-cloudflare-remote-access.sh." + return + } + let command = "cd \(shellQuoted(FileManager.default.currentDirectoryPath)) && \(shellQuoted(script.path)) login" + runInTerminal(command) + outputLabel.stringValue = "Cloudflare sign-in opened in Terminal. Return here after the browser flow completes." + detailsLabel.stringValue = command + } + + @objc private func configurePermanent() { + let hostname = hostnameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let rawTunnelName = tunnelNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let tunnelName = rawTunnelName.isEmpty ? "codepilot" : rawTunnelName + guard !hostname.isEmpty else { + outputLabel.stringValue = "Enter a hostname such as codepilot.example.com." + return + } + + runCloudflareSteps([ + ["configure-permanent", "--hostname", hostname, "--tunnel-name", tunnelName], + ["install-service"], + ["verify", "--url", "https://\(hostname)"] + ], successMessage: "Remote access is configured for https://\(hostname).") + } + + @objc private func startTemporary() { + runCloudflareStep(["start-trycloudflare"], successMessage: "Temporary Cloudflare URL started. Use this only for testing.") + } + + @objc private func openCloudflareDashboard() { + if let url = URL(string: "https://dash.cloudflare.com/") { + NSWorkspace.shared.open(url) + } + } + + @objc private func closeSheet() { + guard let window else { return } + window.sheetParent?.endSheet(window) + } + + private func runCloudflareStep(_ arguments: [String], successMessage: String) { + runCloudflareSteps([arguments], successMessage: successMessage) + } + + private func runCloudflareSteps(_ steps: [[String]], successMessage: String) { + guard let script = scriptURL() else { + outputLabel.stringValue = "Could not find setup-cloudflare-remote-access.sh." + return + } + outputLabel.stringValue = "Running Cloudflare setup..." + detailsLabel.stringValue = steps.map { ([script.path] + $0).joined(separator: " ") }.joined(separator: "\n") + + DispatchQueue.global(qos: .userInitiated).async { + var combinedOutput: [String] = [] + for arguments in steps { + let result = self.runProcess(script: script, arguments: arguments) + combinedOutput.append(result.output) + if result.status != 0 { + DispatchQueue.main.async { + self.outputLabel.stringValue = CodePilotCloudflareErrorMapper.message(forExitCode: result.status) + self.detailsLabel.stringValue = combinedOutput.joined(separator: "\n\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + return + } + } + + DispatchQueue.main.async { + self.outputLabel.stringValue = successMessage + self.detailsLabel.stringValue = combinedOutput.joined(separator: "\n\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + } + } + + private func runProcess(script: URL, arguments: [String]) -> (status: Int32, output: String) { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = [script.path] + arguments + process.standardOutput = pipe + process.standardError = pipe + process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (process.terminationStatus, output) + } catch { + return (1, error.localizedDescription) + } + } + + private func scriptURL() -> URL? { + let candidates = [ + Bundle.main.resourceURL?.appendingPathComponent("scripts/setup-cloudflare-remote-access.sh"), + URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("scripts/setup-cloudflare-remote-access.sh") + ].compactMap { $0 } + return candidates.first { FileManager.default.fileExists(atPath: $0.path) } + } + + private func runInTerminal(_ command: String) { + let script = """ + tell application "Terminal" + activate + do script "\(command.replacingOccurrences(of: "\"", with: "\\\""))" + end tell + """ + var error: NSDictionary? + NSAppleScript(source: script)?.executeAndReturnError(&error) + if let error { + outputLabel.stringValue = String(describing: error) + } + } + + private func shellQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } +} + +private enum RemoteDesktopRPCValidationError: Error { + case invalidRequest +} + +private struct RemoteDesktopSignalResponse: Codable { + let signals: [MacPeerSignal] +} + +private final class RemoteDesktopResultBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Result? + + func store(_ value: Result) { + lock.lock() + self.value = value + lock.unlock() + } + + func load() -> Result? { + lock.lock() + defer { lock.unlock() } + return value + } +} + +private final class GatewayRemoteInputValidator: RemoteInputLeaseValidating { + private let lock = NSLock() + private var lastSequenceBySession: [String: UInt64] = [:] + + func validateInput(sessionID: String, sequence: UInt64) throws { + lock.lock() + defer { lock.unlock() } + if let last = lastSequenceBySession[sessionID], sequence <= last { + throw RemoteDesktopSecurityError.sequenceReplay + } + lastSequenceBySession[sessionID] = sequence + } +} + final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let switcher = CodexAccountSwitcher() + private let remoteFrameCaptureService = RemoteFrameCaptureService() + private let remoteInputInjector = RemoteInputInjector(validator: GatewayRemoteInputValidator()) + private lazy var macPeerConnection = MacPeerConnection { [weak self] event in + guard let self else { return nil } + let displayFrame = CGDisplayBounds(CGMainDisplayID()) + try self.remoteInputInjector.handle(event, displayFrame: displayFrame) + let cursor = CGEvent(source: nil)?.location ?? .zero + return CGPoint( + x: min(1, max(0, (cursor.x - displayFrame.minX) / displayFrame.width)), + y: min(1, max(0, (cursor.y - displayFrame.minY) / displayFrame.height)) + ) + } + private var remoteDesktopCoordinator: RemoteDesktopCoordinator? + private var remoteDesktopSocketServer: RemoteDesktopSocketServer? private var statusItem: NSStatusItem! private let menu = NSMenu() private var setupWindowController: CodePilotSetupWindowController? + private var remoteDesktopWindowController: RemoteDesktopWindowController? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + if !CGPreflightScreenCaptureAccess() { + _ = CGRequestScreenCaptureAccess() + } + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem.button?.title = "CodePilot" menu.delegate = self statusItem.menu = menu + remoteDesktopCoordinator = try? RemoteDesktopCoordinator() + if let remoteDesktopCoordinator { + do { + let server = RemoteDesktopSocketServer { [weak self, weak remoteDesktopCoordinator] request in + guard let self, let remoteDesktopCoordinator else { + return Self.remoteDesktopRPCError(request.id, status: 503, code: "host_unavailable") + } + return self.handleRemoteDesktopRPC(request, coordinator: remoteDesktopCoordinator) + } + try server.start() + remoteDesktopSocketServer = server + } catch { + NSLog("CodePilot remote desktop host failed to start: \(error.localizedDescription)") + } + } switcher.onChange = { [weak self] in self?.updateStatusTitle() } switcher.start() updateStatusTitle() @@ -2477,6 +3069,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let usage = self.switcher.usage(for: active) let warning = self.switcher.staleAuthProfiles().isEmpty ? "" : "! " self.statusItem.button?.title = "CodePilot: \(warning)\(active) \(self.compactRateLimitSummary(usage))" + let isRemoteControlled = self.remoteDesktopCoordinator?.snapshot.activeSession != nil + self.statusItem.button?.contentTintColor = isRemoteControlled ? .systemRed : nil } } @@ -2517,6 +3111,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { setup.target = self menu.addItem(setup) + let remoteDesktop = NSMenuItem(title: "Remote Desktop...", action: #selector(openRemoteDesktop), keyEquivalent: "r") + remoteDesktop.target = self + remoteDesktop.isEnabled = remoteDesktopCoordinator != nil + menu.addItem(remoteDesktop) + let profiles = switcher.profiles() if profiles.isEmpty { let item = NSMenuItem(title: "No account profiles found", action: nil, keyEquivalent: "") @@ -2604,6 +3203,269 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { NSApp.activate(ignoringOtherApps: true) } + @objc private func openRemoteDesktop() { + guard let remoteDesktopCoordinator else { return } + if remoteDesktopWindowController == nil { + remoteDesktopWindowController = RemoteDesktopWindowController(coordinator: remoteDesktopCoordinator) + } + remoteDesktopWindowController?.showWindow(nil) + remoteDesktopWindowController?.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func handleRemoteDesktopRPC( + _ request: HostRPCRequest, + coordinator: RemoteDesktopCoordinator + ) -> HostRPCResponse { + do { + switch request.method { + case "status": + coordinator.refreshStatus() + let status = coordinator.snapshot + let displayFrame = CGDisplayBounds(CGMainDisplayID()) + let cursor = CGEvent(source: nil)?.location ?? .zero + return try Self.remoteDesktopRPCJSON(request.id, [ + "ok": true, + "screenRecordingGranted": status.screenRecordingGranted, + "accessibilityGranted": status.accessibilityGranted, + "macUnlocked": status.macUnlocked, + "trustedDeviceCount": status.trustedDevices.count, + "displayFrame": [ + "width": displayFrame.width, + "height": displayFrame.height + ], + "cursor": [ + "x": min(1, max(0, (cursor.x - displayFrame.minX) / displayFrame.width)), + "y": min(1, max(0, (cursor.y - displayFrame.minY) / displayFrame.height)) + ], + "capabilities": [ + "pairing": true, + "sessions": true, + "screen": true, + "input": status.accessibilityGranted + ] + ]) + + case "pairing.start": + let payload = try Self.remoteDesktopRPCPayload(request.payload) + let deviceID = try Self.remoteDesktopString(payload["deviceId"]) + let name = try Self.remoteDesktopString(payload["name"]) + let publicKey = try Self.remoteDesktopBase64(payload["publicKey"]) + let pending = try coordinator.beginPairing( + deviceID: deviceID, + name: name, + publicKeyRawRepresentation: publicKey, + macName: Host.current().localizedName ?? "Mac" + ) + return try Self.remoteDesktopRPCCodable(request.id, pending.challenge) + + case "pairing.complete": + let payload = try Self.remoteDesktopRPCPayload(request.payload) + 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, + deviceID: deviceID, + signature: signature + ) + let device = try coordinator.pairingStore.approveDevice(using: token) + coordinator.refreshStatus() + return try Self.remoteDesktopRPCCodable(request.id, device) + + case "devices.list": + coordinator.refreshStatus() + return try Self.remoteDesktopRPCJSON(request.id, [ + "devices": try Self.remoteDesktopJSONArray(coordinator.snapshot.trustedDevices) + ]) + + case "devices.revoke": + let payload = try Self.remoteDesktopRPCPayload(request.payload) + let deviceID = try Self.remoteDesktopString(payload["deviceId"]) + let device = try coordinator.pairingStore.revokeDevice(id: deviceID) + coordinator.refreshStatus() + return try Self.remoteDesktopRPCCodable(request.id, device) + + case "audit.list": + coordinator.refreshStatus() + return try Self.remoteDesktopRPCJSON(request.id, [ + "events": try Self.remoteDesktopJSONArray(coordinator.snapshot.auditEvents), + "nextCursor": NSNull() + ]) + + case "frame.capture": + let frame = try remoteFrameCaptureService.captureMainDisplayJPEG() + return HostRPCResponse(id: request.id, status: 200, payload: frame, errorCode: nil) + + case "session.signal": + let payload = try Self.remoteDesktopRPCPayload(request.payload) + let sessionID = try Self.remoteDesktopString(payload["sessionId"]) + let sequence = try Self.remoteDesktopUInt64(payload["sequence"]) + let kindRaw = try Self.remoteDesktopString(payload["kind"]) + guard let kind = MacPeerSignal.Kind(rawValue: kindRaw) else { + throw RemoteDesktopRPCValidationError.invalidRequest + } + let signalPayload = try Self.remoteDesktopBase64(payload["payload"]) + if macPeerConnection.state == .idle || macPeerConnection.state == .disconnected(leaseID: sessionID) { + macPeerConnection.start(leaseID: sessionID) + } + let signal = MacPeerSignal( + leaseID: sessionID, + sequence: sequence, + kind: kind, + payload: signalPayload + ) + try macPeerConnection.acceptRemoteSignal(signal) + guard kind == .offer, let offer = String(data: signalPayload, encoding: .utf8) else { + return try Self.remoteDesktopRPCCodable(request.id, RemoteDesktopSignalResponse(signals: [])) + } + let answer = try Self.remoteDesktopAwait { + try await self.macPeerConnection.answer(offerSDP: offer) + } + let answerSignal = MacPeerSignal( + leaseID: sessionID, + sequence: 1, + kind: .answer, + payload: Data(answer.utf8) + ) + return try Self.remoteDesktopRPCCodable( + request.id, + RemoteDesktopSignalResponse(signals: [answerSignal]) + ) + + case "session.end": + macPeerConnection.disconnect() + return try Self.remoteDesktopRPCJSON(request.id, ["ok": true]) + + case "input.inject": + coordinator.refreshStatus() + guard coordinator.snapshot.accessibilityGranted else { + return Self.remoteDesktopRPCError(request.id, status: 403, code: "accessibility_required") + } + let decoder = JSONDecoder() + let event = try decoder.decode(RemoteInputEvent.self, from: request.payload) + let displayFrame = CGDisplayBounds(CGMainDisplayID()) + try remoteInputInjector.handle(event, displayFrame: displayFrame) + let cursor = CGEvent(source: nil)?.location ?? .zero + return try Self.remoteDesktopRPCJSON(request.id, [ + "ok": true, + "cursor": [ + "x": min(1, max(0, (cursor.x - displayFrame.minX) / displayFrame.width)), + "y": min(1, max(0, (cursor.y - displayFrame.minY) / displayFrame.height)) + ] + ]) + + default: + return Self.remoteDesktopRPCError(request.id, status: 404, code: "unsupported_method") + } + } catch { + return Self.remoteDesktopRPCError( + request.id, + status: 400, + code: Self.remoteDesktopRPCErrorCode(error) + ) + } + } + + private static func remoteDesktopRPCPayload(_ data: Data) throws -> [String: Any] { + if data.isEmpty { + return [:] + } + let object = try JSONSerialization.jsonObject(with: data) + guard let payload = object as? [String: Any] else { + throw RemoteDesktopRPCValidationError.invalidRequest + } + return payload + } + + private static func remoteDesktopString(_ value: Any?) throws -> String { + guard let string = value as? String, !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw RemoteDesktopRPCValidationError.invalidRequest + } + return string + } + + private static func remoteDesktopUInt64(_ value: Any?) throws -> UInt64 { + if let number = value as? NSNumber, number.uint64Value > 0 { + return number.uint64Value + } + throw RemoteDesktopRPCValidationError.invalidRequest + } + + private static func remoteDesktopBase64(_ value: Any?) throws -> Data { + let string = try remoteDesktopString(value) + guard let data = Data(base64Encoded: string) else { + throw RemoteDesktopRPCValidationError.invalidRequest + } + return data + } + + private static func remoteDesktopRPCCodable(_ id: UUID, _ value: T) throws -> HostRPCResponse { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.dataEncodingStrategy = .base64 + return HostRPCResponse(id: id, status: 200, payload: try encoder.encode(value), errorCode: nil) + } + + private static func remoteDesktopJSONArray(_ values: [T]) throws -> [[String: Any]] { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.dataEncodingStrategy = .base64 + let data = try encoder.encode(values) + return (try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) ?? [] + } + + private static func remoteDesktopRPCJSON(_ id: UUID, _ value: [String: Any]) throws -> HostRPCResponse { + HostRPCResponse( + id: id, + status: 200, + payload: try JSONSerialization.data(withJSONObject: value), + errorCode: nil + ) + } + + private static func remoteDesktopRPCError(_ id: UUID, status: Int, code: String) -> HostRPCResponse { + HostRPCResponse(id: id, status: status, payload: Data(), errorCode: code) + } + + private static func remoteDesktopRPCErrorCode(_ error: Error) -> String { + switch error as? RemoteDesktopSecurityError { + case .challengeExpired, .challengeAlreadyUsed, .challengeUnknown: + return "pairing_expired" + case .invalidSignature: + return "invalid_signature" + case .deviceRevoked, .untrustedDevice: + return "untrusted_device" + default: + return "invalid_request" + } + } + + private static func remoteDesktopAwait( + timeout: TimeInterval = 10, + operation: @escaping () async throws -> T + ) throws -> T { + let semaphore = DispatchSemaphore(value: 0) + let result = RemoteDesktopResultBox() + Task { + let value: Result + do { value = .success(try await operation()) } + catch { value = .failure(error) } + result.store(value) + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + timeout) == .success else { + throw RemoteDesktopRPCValidationError.invalidRequest + } + guard let value = result.load() else { + throw RemoteDesktopRPCValidationError.invalidRequest + } + return try value.get() + } + @objc private func switchToProfile(_ sender: NSMenuItem) { guard let name = sender.representedObject as? String else { return } switcher.switchToAccount(named: name) diff --git a/Tests/CodexAccountSwitcherTests/ActiveTurnTrackerTests.swift b/Tests/CodexAccountSwitcherTests/ActiveTurnTrackerTests.swift index f5988cd1..696e62bf 100644 --- a/Tests/CodexAccountSwitcherTests/ActiveTurnTrackerTests.swift +++ b/Tests/CodexAccountSwitcherTests/ActiveTurnTrackerTests.swift @@ -146,4 +146,36 @@ final class ActiveTurnTrackerTests: XCTestCase { ["phone:thread-b/job-b"] ) } + + func testAuthProfileSynchronizerPersistsNewerActiveAuthToActiveProfile() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let codexDir = root.appendingPathComponent("codex", isDirectory: true) + let accountsDir = root.appendingPathComponent("accounts", isDirectory: true) + let mainDir = accountsDir.appendingPathComponent("Main", isDirectory: true) + try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: mainDir, withIntermediateDirectories: true) + + let activeAuth = codexDir.appendingPathComponent("auth.json") + let marker = root.appendingPathComponent("active-account.txt") + let profileAuth = mainDir.appendingPathComponent("auth.json") + + try #"{"last_refresh":"2026-06-24T08:00:00Z","tokens":{"account_id":"main-account","access_token":"active-refreshed"}}"# + .write(to: activeAuth, atomically: true, encoding: .utf8) + try #"{"last_refresh":"2026-06-23T08:00:00Z","tokens":{"account_id":"main-account","access_token":"old-profile"}}"# + .write(to: profileAuth, atomically: true, encoding: .utf8) + try "Main\n".write(to: marker, atomically: true, encoding: .utf8) + + let didSync = try AuthProfileSynchronizer.syncActiveAuthToProfile( + activeAuth: activeAuth, + activeAccountMarker: marker, + accountsDir: accountsDir + ) + + XCTAssertTrue(didSync) + let persisted = try String(contentsOf: profileAuth, encoding: .utf8) + XCTAssertTrue(persisted.contains("active-refreshed")) + } } diff --git a/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift b/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift new file mode 100644 index 00000000..8e56e823 --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import CodexAccountSwitcher + +final class CloudflareSetupTests: XCTestCase { + func testCloudflareStatusLabelsAreSpecific() { + XCTAssertEqual(CodePilotSetupRequirement.cloudflareMissing.statusLabel, "Missing") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareNeedsConfiguration.statusLabel, "Needs setup") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareReady.statusLabel, "Ready") + } + + func testCloudflareMetadataDoesNotExposeSecrets() throws { + let metadata = CodePilotCloudflareMetadata( + mode: "permanent", + hostname: "codepilot.example.com", + tunnelName: "codepilot", + tunnelId: "tun_123", + configPath: "/Users/test/.cloudflared/codepilot-config.yaml", + launchAgentLabel: "io.codepilot.phone-cloudflared", + lastVerifiedAt: nil + ) + + let summary = metadata.safeSummary + XCTAssertTrue(summary.contains("codepilot.example.com")) + XCTAssertFalse(summary.localizedCaseInsensitiveContains("token")) + XCTAssertFalse(summary.localizedCaseInsensitiveContains("credential")) + } + + func testCloudflareScriptErrorMapsToRecoveryCopy() { + XCTAssertEqual( + CodePilotCloudflareErrorMapper.message(forExitCode: 20), + "Homebrew is missing. Install Homebrew or use Cloudflare's manual cloudflared installer, then retry." + ) + XCTAssertEqual( + CodePilotCloudflareErrorMapper.message(forExitCode: 21), + "cloudflared is missing. Install it from the Cloudflare setup step before continuing." + ) + } +} diff --git a/Tests/CodexAccountSwitcherTests/InputInjectorTests.swift b/Tests/CodexAccountSwitcherTests/InputInjectorTests.swift new file mode 100644 index 00000000..b95aff5d --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/InputInjectorTests.swift @@ -0,0 +1,207 @@ +import CoreGraphics +import Foundation +import XCTest +@testable import CodexAccountSwitcher + +final class InputInjectorTests: XCTestCase { + func testMapsNormalizedPointToNegativeOriginDisplay() { + let frame = CGRect(x: -1920, y: 0, width: 1920, height: 1080) + + XCTAssertEqual( + DisplayCoordinateMapper.map(x: 0.5, y: 0.5, into: frame), + CGPoint(x: -960, y: 540) + ) + } + + func testClampsNormalizedCoordinatesToDisplayBounds() { + let frame = CGRect(x: 10, y: 20, width: 100, height: 50) + + XCTAssertEqual(DisplayCoordinateMapper.map(x: -1, y: 2, into: frame), CGPoint(x: 10, y: 70)) + } + + func testRejectsInputWhenMacLockedOrLeaseInvalid() throws { + let validator = FakeRemoteInputLeaseValidator() + let sink = RecordingRemoteInputSink() + let injector = RemoteInputInjector(validator: validator, sink: sink) + let event = RemoteInputEvent( + sessionId: "lease-1", + sequence: 1, + kind: .pointer, + x: 0.5, + y: 0.5, + button: nil, + keyCode: nil, + text: nil, + deltaX: nil, + deltaY: nil + ) + + validator.isMacUnlocked = false + XCTAssertThrowsError(try injector.handle(event, displayFrame: CGRect(x: 0, y: 0, width: 100, height: 100))) + XCTAssertTrue(sink.events.isEmpty) + + validator.isMacUnlocked = true + validator.accepted = false + XCTAssertThrowsError(try injector.handle(event, displayFrame: CGRect(x: 0, y: 0, width: 100, height: 100))) + XCTAssertTrue(sink.events.isEmpty) + } + + func testRejectsStaleSequenceBeforePostingEvents() throws { + let validator = FakeRemoteInputLeaseValidator() + let sink = RecordingRemoteInputSink() + let injector = RemoteInputInjector(validator: validator, sink: sink) + let first = RemoteInputEvent( + sessionId: "lease-1", + sequence: 1, + kind: .pointer, + x: 0.25, + y: 0.25, + button: nil, + keyCode: nil, + text: nil, + deltaX: nil, + deltaY: nil + ) + let replay = RemoteInputEvent( + sessionId: "lease-1", + sequence: 1, + kind: .scroll, + x: nil, + y: nil, + button: nil, + keyCode: nil, + text: nil, + deltaX: 1, + deltaY: 1 + ) + + try injector.handle(first, displayFrame: CGRect(x: 0, y: 0, width: 100, height: 100)) + XCTAssertThrowsError(try injector.handle(replay, displayFrame: CGRect(x: 0, y: 0, width: 100, height: 100))) + + XCTAssertEqual(sink.events.count, 1) + } + + func testReleasesPressedStateOnDisconnect() throws { + let sink = RecordingRemoteInputSink() + let injector = RemoteInputInjector(validator: FakeRemoteInputLeaseValidator(), sink: sink) + let keyDown = RemoteInputEvent( + sessionId: "lease-1", + sequence: 1, + kind: .keyDown, + x: nil, + y: nil, + button: nil, + keyCode: 55, + text: nil, + deltaX: nil, + deltaY: nil + ) + let buttonDown = RemoteInputEvent( + sessionId: "lease-1", + sequence: 2, + kind: .buttonDown, + x: 0.5, + y: 0.5, + button: 0, + keyCode: nil, + text: nil, + deltaX: nil, + deltaY: nil + ) + + try injector.handle(keyDown, displayFrame: CGRect(x: 0, y: 0, width: 100, height: 100)) + try injector.handle(buttonDown, displayFrame: CGRect(x: 0, y: 0, width: 100, height: 100)) + injector.releaseAll() + + XCTAssertTrue(sink.events.contains(.keyUp(55))) + XCTAssertTrue(sink.events.contains(.buttonUp(0, CGPoint(x: 50, y: 50)))) + } + + func testPointerWithDeltasMovesRelativeToCurrentCursor() throws { + let sink = RecordingRemoteInputSink() + sink.pointerPosition = CGPoint(x: 400, y: 300) + let injector = RemoteInputInjector(validator: FakeRemoteInputLeaseValidator(), sink: sink) + let event = RemoteInputEvent( + sessionId: "lease-relative", + sequence: 1, + kind: .pointer, + x: nil, + y: nil, + button: nil, + keyCode: nil, + text: nil, + deltaX: 24, + deltaY: -12 + ) + + try injector.handle(event, displayFrame: CGRect(x: 0, y: 0, width: 1000, height: 800)) + + XCTAssertEqual(sink.events, [.pointer(CGPoint(x: 424, y: 288))]) + } + + func testAbsoluteButtonDownMovesPointerBeforeClicking() throws { + let sink = RecordingRemoteInputSink() + sink.pointerPosition = CGPoint(x: 10, y: 10) + let injector = RemoteInputInjector(validator: FakeRemoteInputLeaseValidator(), sink: sink) + let event = RemoteInputEvent( + sessionId: "lease-click", + sequence: 1, + kind: .buttonDown, + x: 0.25, + y: 0.75, + button: 0, + keyCode: nil, + text: nil, + deltaX: nil, + deltaY: nil + ) + + try injector.handle(event, displayFrame: CGRect(x: 0, y: 0, width: 1000, height: 800)) + + let target = CGPoint(x: 250, y: 600) + XCTAssertEqual(sink.events, [ + .pointer(target), + .buttonDown(0, target) + ]) + } +} + +final class FakeRemoteInputLeaseValidator: RemoteInputLeaseValidating { + var isMacUnlocked = true + var accepted = true + private var lastSequence: UInt64 = 0 + + func validateInput(sessionID: String, sequence: UInt64) throws { + guard isMacUnlocked else { throw RemoteDesktopSecurityError.leaseExpired } + guard accepted else { throw RemoteDesktopSecurityError.leaseUnknown } + guard sequence > lastSequence else { throw RemoteDesktopSecurityError.sequenceReplay } + lastSequence = sequence + } +} + +final class RecordingRemoteInputSink: RemoteInputPosting { + enum Event: Equatable { + case pointer(CGPoint) + case buttonDown(Int, CGPoint) + case buttonUp(Int, CGPoint) + case scroll(Double, Double) + case keyDown(UInt16) + case keyUp(UInt16) + case text(String) + } + + var events: [Event] = [] + var pointerPosition: CGPoint = .zero + + func currentPointerPosition() -> CGPoint { pointerPosition } + func movePointer(to point: CGPoint) { + pointerPosition = point + events.append(.pointer(point)) + } + func buttonDown(_ button: Int, at point: CGPoint) { events.append(.buttonDown(button, point)) } + func buttonUp(_ button: Int, at point: CGPoint) { events.append(.buttonUp(button, point)) } + func scroll(deltaX: Double, deltaY: Double) { events.append(.scroll(deltaX, deltaY)) } + func keyDown(_ keyCode: UInt16) { events.append(.keyDown(keyCode)) } + func keyUp(_ keyCode: UInt16) { events.append(.keyUp(keyCode)) } + func text(_ text: String) { events.append(.text(text)) } +} diff --git a/Tests/CodexAccountSwitcherTests/MacPeerConnectionTests.swift b/Tests/CodexAccountSwitcherTests/MacPeerConnectionTests.swift new file mode 100644 index 00000000..c44596fe --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/MacPeerConnectionTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest +@testable import CodexAccountSwitcher + +final class MacPeerConnectionTests: XCTestCase { + func testAcceptsSignalsOnlyForActiveLeaseAndIncreasingSequence() throws { + let peer = MacPeerConnection() + peer.start(leaseID: "lease-1") + + try peer.acceptRemoteSignal(MacPeerSignal( + leaseID: "lease-1", + sequence: 1, + kind: .offer, + payload: Data("sdp".utf8) + )) + + XCTAssertThrowsError(try peer.acceptRemoteSignal(MacPeerSignal( + leaseID: "lease-2", + sequence: 2, + kind: .answer, + payload: Data() + ))) + XCTAssertThrowsError(try peer.acceptRemoteSignal(MacPeerSignal( + leaseID: "lease-1", + sequence: 1, + kind: .ice, + payload: Data() + ))) + } + + func testDisconnectClearsPendingSignalsAndState() throws { + let peer = MacPeerConnection() + peer.start(leaseID: "lease-1") + peer.markConnected() + try peer.enqueueLocalSignal(kind: .answer, payload: Data("answer".utf8)) + + XCTAssertEqual(peer.drainLocalSignals().count, 1) + try peer.enqueueLocalSignal(kind: .ice, payload: Data("ice".utf8)) + peer.disconnect() + + XCTAssertEqual(peer.state, .disconnected(leaseID: "lease-1")) + XCTAssertTrue(peer.drainLocalSignals().isEmpty) + XCTAssertThrowsError(try peer.enqueueLocalSignal(kind: .ice, payload: Data())) + } +} diff --git a/Tests/CodexAccountSwitcherTests/PairingStoreTests.swift b/Tests/CodexAccountSwitcherTests/PairingStoreTests.swift new file mode 100644 index 00000000..c25b33e0 --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/PairingStoreTests.swift @@ -0,0 +1,211 @@ +import CryptoKit +import Foundation +import XCTest +@testable import CodexAccountSwitcher + +final class PairingStoreTests: XCTestCase { + func testChallengeCanBeUsedOnceAndExpiresAfterTwoMinutes() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let store = try makeStore(clock: { now }) + let privateKey = P256.Signing.PrivateKey() + + let challenge = store.issueChallenge( + deviceID: "device-1", + name: "Taylor's iPhone", + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Studio Mac" + ) + let token = try store.verifyChallenge( + challenge, + deviceID: "device-1", + signature: try privateKey.signature(for: Data(challenge.code.utf8)).derRepresentation + ) + _ = try store.approveDevice(using: token) + + XCTAssertThrowsError(try store.approveDevice(using: token)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .challengeAlreadyUsed) + } + + _ = try store.revokeDevice(id: "device-1") + XCTAssertThrowsError(try store.approveDevice(using: token)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .challengeAlreadyUsed) + } + + var expiredNow = Date(timeIntervalSince1970: 1_700_000_000) + let expiredStore = try makeStore(clock: { expiredNow }) + let expiredChallenge = expiredStore.issueChallenge( + deviceID: "device-1", + name: "Taylor's iPhone", + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Studio Mac" + ) + expiredNow = expiredNow.addingTimeInterval(121) + XCTAssertThrowsError( + try expiredStore.verifyChallenge( + expiredChallenge, + deviceID: "device-1", + signature: try privateKey.signature(for: Data(expiredChallenge.code.utf8)).derRepresentation + ) + ) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .challengeExpired) + } + } + + func testNonceVerificationIsSingleUseAndPersistsOnlyAfterSuccess() throws { + let store = try makeStore() + let privateKey = P256.Signing.PrivateKey() + let challenge = store.issueChallenge( + deviceID: "device-1", + name: "Taylor's iPhone", + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Studio Mac" + ) + let token = try store.verifyChallenge( + challenge, + deviceID: "device-1", + signature: try privateKey.signature(for: Data(challenge.code.utf8)).derRepresentation + ) + _ = try store.approveDevice(using: token) + + let nonce = "nonce-1" + let proof = try store.verifyNonce( + deviceID: "device-1", + nonce: nonce, + signature: try privateKey.signature(for: Data(nonce.utf8)).derRepresentation + ) + + XCTAssertThrowsError( + try store.verifyNonce( + deviceID: "device-1", + nonce: nonce, + signature: try privateKey.signature(for: Data(nonce.utf8)).derRepresentation + ) + ) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .nonceAlreadyUsed) + } + + XCTAssertEqual(proof.nonce, nonce) + } + + func testVerifySignatureRejectsInvalidSignature() throws { + let store = try makeStore() + let privateKey = P256.Signing.PrivateKey() + let wrongPrivateKey = P256.Signing.PrivateKey() + let challenge = store.issueChallenge( + deviceID: "device-1", + name: "Taylor's iPhone", + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Studio Mac" + ) + let token = try store.verifyChallenge( + challenge, + deviceID: "device-1", + signature: try privateKey.signature(for: Data(challenge.code.utf8)).derRepresentation + ) + _ = try store.approveDevice(using: token) + + XCTAssertThrowsError( + try store.verify( + signature: try wrongPrivateKey.signature(for: Data("message".utf8)).derRepresentation, + message: Data("message".utf8), + deviceID: "device-1" + ) + ) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .invalidSignature) + } + } + + func testVerifySignatureRejectsRevokedDevice() throws { + let store = try makeStore() + let privateKey = P256.Signing.PrivateKey() + let challenge = store.issueChallenge( + deviceID: "device-1", + name: "Taylor's iPhone", + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Studio Mac" + ) + let token = try store.verifyChallenge( + challenge, + deviceID: "device-1", + signature: try privateKey.signature(for: Data(challenge.code.utf8)).derRepresentation + ) + _ = try store.approveDevice(using: token) + _ = try store.revokeDevice(id: "device-1") + + XCTAssertThrowsError( + try store.verify( + signature: try privateKey.signature(for: Data("message".utf8)).derRepresentation, + message: Data("message".utf8), + deviceID: "device-1" + ) + ) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .deviceRevoked) + } + } + + func testReplayedApprovalTokenDoesNotReapproveAfterRevocation() throws { + let store = try makeStore() + let privateKey = P256.Signing.PrivateKey() + let challenge = store.issueChallenge( + deviceID: "device-1", + name: "Taylor's iPhone", + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Studio Mac" + ) + let token = try store.verifyChallenge( + challenge, + deviceID: "device-1", + signature: try privateKey.signature(for: Data(challenge.code.utf8)).derRepresentation + ) + _ = try store.approveDevice(using: token) + _ = try store.revokeDevice(id: "device-1") + + XCTAssertThrowsError(try store.approveDevice(using: token)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .challengeAlreadyUsed) + } + XCTAssertNotNil(store.trustedDevice(id: "device-1")?.revokedAt) + } + + func testApprovalDoesNotMutateInMemoryStateWhenPersistenceFails() throws { + let store = try makeFailingPersistenceStore() + let privateKey = P256.Signing.PrivateKey() + let challenge = store.issueChallenge( + deviceID: "device-1", + name: "Taylor's iPhone", + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Studio Mac" + ) + let token = try store.verifyChallenge( + challenge, + deviceID: "device-1", + signature: try privateKey.signature(for: Data(challenge.code.utf8)).derRepresentation + ) + + XCTAssertThrowsError(try store.approveDevice(using: token)) + XCTAssertNil(store.trustedDevice(id: "device-1")) + } + + private func makeStore(clock: @escaping PairingStore.Clock = Date.init) throws -> PairingStore { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("trusted-devices.json") + addTeardownBlock { + try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) + } + return try PairingStore(fileURL: fileURL, clock: clock) + } + + private func makeFailingPersistenceStore() throws -> PairingStore { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("trusted-devices.json") + addTeardownBlock { + try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) + } + return try PairingStore( + fileURL: fileURL, + clock: Date.init, + persistTrustedDevices: { _ in throw NSError(domain: "PairingStoreTests", code: 1) } + ) + } +} diff --git a/Tests/CodexAccountSwitcherTests/RemoteDesktopAuditLogTests.swift b/Tests/CodexAccountSwitcherTests/RemoteDesktopAuditLogTests.swift new file mode 100644 index 00000000..72013d18 --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/RemoteDesktopAuditLogTests.swift @@ -0,0 +1,119 @@ +import Foundation +import XCTest +@testable import CodexAccountSwitcher + +final class RemoteDesktopAuditLogTests: XCTestCase { + func testAuditLogRejectsUnsafeIdentifiersBeforeWriting() throws { + let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let fileURL = rootURL.appendingPathComponent("audit.jsonl") + addTeardownBlock { + try? FileManager.default.removeItem(at: rootURL) + } + + let log = try RemoteDesktopAuditLog(fileURL: fileURL, clock: { Date(timeIntervalSince1970: 1_700_000_123) }) + let unsafeEvent = RemoteDesktopAuditEvent( + id: "event-bearing-bearer-secret", + timestamp: Date(timeIntervalSince1970: 1_700_000_123), + kind: .leaseGranted, + deviceId: "device-1", + sessionId: "session-1", + leaseId: "lease-1", + sequence: 7, + reason: .busy + ) + + XCTAssertThrowsError(try log.append(unsafeEvent)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .prohibitedAuditContent) + } + XCTAssertEqual((try Data(contentsOf: fileURL)).count, 0) + } + + func testAuditLogIgnoresTruncatedTrailingRecord() throws { + let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let fileURL = rootURL.appendingPathComponent("audit.jsonl") + addTeardownBlock { + try? FileManager.default.removeItem(at: rootURL) + } + + let validEvent = RemoteDesktopAuditEvent( + id: "event-1", + timestamp: Date(timeIntervalSince1970: 1_700_000_123), + kind: .leaseGranted, + deviceId: "device-1", + sessionId: "session-1", + leaseId: "lease-1", + sequence: 7, + reason: .busy + ) + let truncatedTail = #"{"id":"event-2","timestamp":"2024-01-01T00:00:00Z","kind":"leaseGranted","deviceId":"device-1""# + let log = try RemoteDesktopAuditLog(fileURL: fileURL) + try log.append(validEvent) + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: truncatedTail.data(using: .utf8)!) + + let decoded = try log.loadAll() + + XCTAssertEqual(decoded, [validEvent]) + } + + func testAuditLogThrowsWhenUnderlyingFileReadFails() throws { + let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let fileURL = rootURL.appendingPathComponent("audit.jsonl") + addTeardownBlock { + try? FileManager.default.removeItem(at: rootURL) + } + + let log = try RemoteDesktopAuditLog(fileURL: fileURL) + try FileManager.default.removeItem(at: fileURL) + + XCTAssertThrowsError(try log.loadAll()) + } + + func testAuditLogUsesOwnerOnlyPermissionsAndRedactsSensitiveShapes() throws { + let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let fileURL = rootURL.appendingPathComponent("audit.jsonl") + addTeardownBlock { + try? FileManager.default.removeItem(at: rootURL) + } + + let timestamp = Date(timeIntervalSince1970: 1_700_000_123) + let log = try RemoteDesktopAuditLog(fileURL: fileURL, clock: { timestamp }) + let event = RemoteDesktopAuditEvent( + id: "event-1", + timestamp: timestamp, + kind: .leaseGranted, + deviceId: "device-1", + sessionId: "session-1", + leaseId: "lease-1", + sequence: 7, + reason: .busy + ) + + try log.append(event) + + let contents = try String(contentsOf: fileURL, encoding: .utf8) + XCTAssertTrue(contents.contains(#""kind":"leaseGranted""#)) + for forbidden in ["screenContent", "keystroke", "clipboardValue", "bearerToken", "privateKey", "turnSecret", "fileContents"] { + XCTAssertFalse(contents.contains(forbidden), "Found prohibited content: \(forbidden)") + } + + let decoded = try log.loadAll() + XCTAssertEqual(decoded, [event]) + + let directoryPermissions = try posixPermissions(for: rootURL) + let filePermissions = try posixPermissions(for: fileURL) + XCTAssertEqual(directoryPermissions, 0o700) + XCTAssertEqual(filePermissions, 0o600) + + let json = try JSONSerialization.jsonObject(with: Data(contentsOf: fileURL), options: []) + let record = try XCTUnwrap(json as? [String: Any]) + XCTAssertEqual(Set(record.keys), ["deviceId", "id", "kind", "leaseId", "reason", "sequence", "sessionId", "timestamp"]) + } + + private func posixPermissions(for url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return (attributes[.posixPermissions] as? NSNumber)?.intValue ?? -1 + } +} diff --git a/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift b/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift new file mode 100644 index 00000000..618f52b0 --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift @@ -0,0 +1,132 @@ +import CryptoKit +import Foundation +import XCTest +@testable import CodexAccountSwitcher + +final class RemoteDesktopCoordinatorTests: XCTestCase { + func testSnapshotReflectsPermissionState() throws { + let permissions = FakeRemoteDesktopPermissions( + screenRecordingGranted: false, + accessibilityGranted: true, + macUnlocked: true + ) + let coordinator = try makeCoordinator(permissions: permissions) + + XCTAssertEqual(coordinator.snapshot.screenRecordingGranted, false) + XCTAssertEqual(coordinator.snapshot.accessibilityGranted, true) + XCTAssertEqual(coordinator.snapshot.macUnlocked, true) + + permissions.screenRecordingGranted = true + coordinator.refreshStatus() + + XCTAssertEqual(coordinator.snapshot.screenRecordingGranted, true) + } + + func testPairingCanBeApprovedOrRejectedLocally() throws { + let coordinator = try makeCoordinator() + let publicKey = P256.Signing.PrivateKey().publicKey.rawRepresentation + + let pending = try coordinator.beginPairing( + deviceID: "phone-1", + name: "Tony's iPhone", + publicKeyRawRepresentation: publicKey, + macName: "Mac mini" + ) + + XCTAssertEqual(coordinator.snapshot.pendingPairing?.deviceID, "phone-1") + XCTAssertFalse(pending.keyFingerprint.isEmpty) + + coordinator.rejectPendingPairing() + XCTAssertNil(coordinator.snapshot.pendingPairing) + + _ = try coordinator.beginPairing( + deviceID: "phone-1", + name: "Tony's iPhone", + publicKeyRawRepresentation: publicKey, + macName: "Mac mini" + ) + try coordinator.approvePendingPairing() + + XCTAssertNil(coordinator.snapshot.pendingPairing) + XCTAssertEqual(coordinator.snapshot.trustedDevices.map(\.id), ["phone-1"]) + } + + func testActiveSessionAndEmergencyDisconnect() throws { + let coordinator = try makeCoordinator() + + coordinator.registerActiveSession(deviceID: "phone-1", leaseID: "lease-1", startedAt: Date(timeIntervalSince1970: 10)) + + XCTAssertEqual(coordinator.snapshot.activeSession?.deviceID, "phone-1") + XCTAssertEqual(coordinator.snapshot.activeSession?.leaseID, "lease-1") + + coordinator.emergencyDisconnect() + + XCTAssertNil(coordinator.snapshot.activeSession) + } + + func testRevokingActiveDeviceClearsTrustedDeviceAndSession() throws { + let coordinator = try makeCoordinator() + let key = P256.Signing.PrivateKey() + let challenge = coordinator.pairingStore.issueChallenge( + deviceID: "phone-1", + name: "Tony's iPhone", + publicKeyRawRepresentation: key.publicKey.rawRepresentation, + macName: "Mac mini" + ) + let approval = try coordinator.pairingStore.verifyChallenge( + challenge, + deviceID: "phone-1", + signature: try key.signature(for: Data(challenge.code.utf8)).derRepresentation + ) + _ = try coordinator.pairingStore.approveDevice(using: approval) + coordinator.refreshStatus() + coordinator.registerActiveSession(deviceID: "phone-1", leaseID: "lease-1", startedAt: Date()) + + try coordinator.revokeDevice(id: "phone-1") + + XCTAssertNotNil(coordinator.snapshot.trustedDevices.first?.revokedAt) + XCTAssertNil(coordinator.snapshot.activeSession) + } + + func testMacLockClearsActiveSessionAndBlocksStatus() throws { + let permissions = FakeRemoteDesktopPermissions(macUnlocked: true) + let coordinator = try makeCoordinator(permissions: permissions) + coordinator.registerActiveSession(deviceID: "phone-1", leaseID: "lease-1", startedAt: Date()) + + permissions.macUnlocked = false + coordinator.handleMacDidLock() + + XCTAssertFalse(coordinator.snapshot.macUnlocked) + XCTAssertNil(coordinator.snapshot.activeSession) + } + + private func makeCoordinator( + permissions: FakeRemoteDesktopPermissions = FakeRemoteDesktopPermissions() + ) throws -> RemoteDesktopCoordinator { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: root) + } + return try RemoteDesktopCoordinator( + permissions: permissions, + pairingStore: PairingStore(fileURL: root.appendingPathComponent("trusted-devices.json")), + auditLog: RemoteDesktopAuditLog(fileURL: root.appendingPathComponent("audit.jsonl")) + ) + } +} + +final class FakeRemoteDesktopPermissions: RemoteDesktopPermissionChecking { + var screenRecordingGranted: Bool + var accessibilityGranted: Bool + var macUnlocked: Bool + + init( + screenRecordingGranted: Bool = true, + accessibilityGranted: Bool = true, + macUnlocked: Bool = true + ) { + self.screenRecordingGranted = screenRecordingGranted + self.accessibilityGranted = accessibilityGranted + self.macUnlocked = macUnlocked + } +} diff --git a/Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift b/Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift new file mode 100644 index 00000000..3e506ae6 --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift @@ -0,0 +1,143 @@ +import Foundation +import XCTest +@testable import CodexAccountSwitcher + +final class RemoteDesktopModelsTests: XCTestCase { + func testCanonicalRemoteInputEventDecodes() throws { + let fixture = #"{"sessionId":"s1","sequence":4,"kind":"pointer","x":0.25,"y":0.75,"button":0,"keyCode":null,"text":null,"deltaX":null,"deltaY":null}"#.data(using: .utf8)! + + let event = try JSONDecoder().decode(RemoteInputEvent.self, from: fixture) + + XCTAssertEqual(event.sessionId, "s1") + XCTAssertEqual(event.sequence, 4) + XCTAssertEqual(event.kind, .pointer) + XCTAssertEqual(event.x, 0.25) + XCTAssertEqual(event.y, 0.75) + XCTAssertEqual(event.button, 0) + XCTAssertNil(event.keyCode) + XCTAssertNil(event.text) + XCTAssertNil(event.deltaX) + XCTAssertNil(event.deltaY) + } + + func testEveryWireModelRoundTrips() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let device = TrustedRemoteDevice( + id: "device-1", + name: "iPhone", + publicKeyRawRepresentation: Data([1, 2, 3, 4]), + approvedAt: date, + revokedAt: nil + ) + let auditEvent = RemoteAuditEvent( + id: "audit-1", + timestamp: date, + kind: "session.started", + deviceId: device.id, + sessionId: "session-1" + ) + let auditRecord = RemoteDesktopAuditEvent( + id: "audit-record-1", + timestamp: date, + kind: .leaseGranted, + deviceId: device.id, + sessionId: "session-1", + leaseId: "lease-1", + sequence: 11, + reason: .busy + ) + + try assertRoundTrip(RemoteInputKind.keyDown) + try assertRoundTrip(RemoteInputEvent( + sessionId: "session-1", + sequence: 9, + kind: .scroll, + x: 0.25, + y: 0.75, + button: nil, + keyCode: nil, + text: nil, + deltaX: 1.5, + deltaY: -2.5 + )) + try assertRoundTrip(RemoteDisplay( + id: 42, + name: "Studio Display", + pixelWidth: 5120, + pixelHeight: 2880, + scale: 2, + rotation: 0 + )) + try assertRoundTrip(RemotePairingChallenge( + id: "challenge-1", + code: "123456", + macName: "Office Mac", + expiresAt: date + )) + try assertRoundTrip(device) + try assertRoundTrip(RemoteDesktopLease( + id: "lease-1", + deviceId: device.id, + expiresAt: date + )) + try assertRoundTrip(RemoteSessionDescription(type: "offer", sdp: "v=0")) + try assertRoundTrip(RemoteICECandidate( + candidate: "candidate:1 1 UDP 1 192.0.2.1 5000 typ host", + sdpMid: "0", + sdpMLineIndex: 0 + )) + try assertRoundTrip(RemoteClipboardDirection.send) + try assertRoundTrip(RemoteClipboardRequest( + sessionId: "session-1", + direction: .send, + text: "hello" + )) + try assertRoundTrip(auditEvent) + try assertRoundTrip(RemoteAuditEventResponse( + events: [auditEvent], + nextCursor: "cursor-2" + )) + try assertRoundTrip(auditRecord) + } + + func testDateAndDataUseStableWireRepresentations() throws { + let device = TrustedRemoteDevice( + id: "device-1", + name: "iPhone", + publicKeyRawRepresentation: Data([1, 2, 3, 4]), + approvedAt: Date(timeIntervalSince1970: 1_700_000_000), + revokedAt: nil + ) + + let data = try wireEncoder().encode(device) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(json["approvedAt"] as? String, "2023-11-14T22:13:20Z") + XCTAssertEqual(json["publicKeyRawRepresentation"] as? String, "AQIDBA==") + XCTAssertEqual(try wireDecoder().decode(TrustedRemoteDevice.self, from: data), device) + } + + private func assertRoundTrip( + _ value: T, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let encoded = try wireEncoder().encode(value) + let decoded = try wireDecoder().decode(T.self, from: encoded) + XCTAssertEqual(decoded, value, file: file, line: line) + } + + private func wireEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.dataEncodingStrategy = .base64 + return encoder + } + + private func wireDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.dataDecodingStrategy = .base64 + return decoder + } +} diff --git a/Tests/CodexAccountSwitcherTests/RemoteDesktopSocketServerTests.swift b/Tests/CodexAccountSwitcherTests/RemoteDesktopSocketServerTests.swift new file mode 100644 index 00000000..08efb6d6 --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/RemoteDesktopSocketServerTests.swift @@ -0,0 +1,335 @@ +import Darwin +import Foundation +import XCTest +@testable import CodexAccountSwitcher + +final class RemoteDesktopSocketServerTests: XCTestCase { + func testDefaultSocketPathUsesHostSocketName() { + XCTAssertEqual(RemoteDesktopSocketServer.defaultSocketURL.lastPathComponent, "host.sock") + } + + func testDispatchesRequestAndReturnsResponse() throws { + let socketURL = try makeSocketURL() + let received = LockedValue(nil) + let server = try makeServer(socketURL: socketURL) { request in + received.set(request) + return HostRPCResponse( + id: request.id, + status: 200, + payload: Data(#"{"ok":true}"#.utf8), + errorCode: nil + ) + } + defer { server.stop() } + + let response = try call(socketURL: socketURL, request: HostRPCRequest( + id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, + method: "status", + payload: Data(#"{"detail":"ready"}"#.utf8) + )) + + let request = try XCTUnwrap(received.value) + XCTAssertEqual(request.method, "status") + XCTAssertEqual(request.payload, Data(#"{"detail":"ready"}"#.utf8)) + XCTAssertEqual(response.id, request.id) + XCTAssertEqual(response.status, 200) + XCTAssertEqual(response.payload, Data(#"{"ok":true}"#.utf8)) + XCTAssertNil(response.errorCode) + } + + func testStaleSocketFileIsCleanedUpAndReplaced() throws { + let socketURL = try makeSocketURL() + try createStaleSocket(at: socketURL) + + let server = try makeServer(socketURL: socketURL) { request in + HostRPCResponse(id: request.id, status: 200, payload: Data(), errorCode: nil) + } + defer { server.stop() } + + let response = try call(socketURL: socketURL, request: HostRPCRequest( + id: UUID(uuidString: "55555555-5555-5555-5555-555555555555")!, + method: "status", + payload: Data() + )) + + XCTAssertEqual(response.status, 200) + XCTAssertEqual(response.payload, Data()) + XCTAssertNil(response.errorCode) + XCTAssertEqual(try permissions(at: socketURL), 0o600) + } + + func testRejectsMalformedAndOversizedRequestsWithoutStoppingServer() throws { + let socketURL = try makeSocketURL() + let server = try makeServer(socketURL: socketURL, maxRequestBytes: 256) { request in + HostRPCResponse(id: request.id, status: 200, payload: Data(), errorCode: nil) + } + defer { server.stop() } + + let malformedResponse = try sendRawLine("{\"method\":\"status\"\n", to: socketURL) + XCTAssertEqual(malformedResponse.status, 400) + XCTAssertEqual(malformedResponse.errorCode, "invalid_json") + XCTAssertEqual(malformedResponse.payload, Data()) + + let unsupportedRequest = try JSONEncoder().encode(HostRPCRequest( + id: UUID(uuidString: "33333333-3333-3333-3333-333333333333")!, + method: "status.debug", + payload: Data() + )) + Data([0x0A]) + let unsupportedResponse = try sendLine(unsupportedRequest, to: socketURL) + XCTAssertEqual(unsupportedResponse.status, 404) + XCTAssertEqual(unsupportedResponse.errorCode, "unsupported_method") + XCTAssertEqual(unsupportedResponse.payload, Data()) + + let oversizedRequest = String(repeating: "x", count: 512) + let oversizedResponse = try sendRawLine(oversizedRequest + "\n", to: socketURL) + XCTAssertEqual(oversizedResponse.status, 413) + XCTAssertEqual(oversizedResponse.errorCode, "request_too_large") + XCTAssertEqual(oversizedResponse.payload, Data()) + + let response = try call(socketURL: socketURL, request: HostRPCRequest( + id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, + method: "status", + payload: Data() + )) + + XCTAssertEqual(response.status, 200) + XCTAssertNil(response.errorCode) + } + + func testStalledClientDoesNotBlockSubsequentRequests() throws { + let socketURL = try makeSocketURL() + let server = try makeServer(socketURL: socketURL) { request in + HostRPCResponse(id: request.id, status: 200, payload: Data(), errorCode: nil) + } + defer { server.stop() } + + let stalledFD = try connectToSocket(socketURL) + defer { close(stalledFD) } + try writeAll(fd: stalledFD, data: Data(#"{"id":"44444444-4444-4444-4444-444444444444","method":"status","payload":""#.utf8)) + + let response = try call(socketURL: socketURL, request: HostRPCRequest( + id: UUID(uuidString: "66666666-6666-6666-6666-666666666666")!, + method: "status", + payload: Data() + )) + + XCTAssertEqual(response.status, 200) + XCTAssertEqual(response.payload, Data()) + } + + func testDisconnectedClientDoesNotCrashServer() throws { + let socketURL = try makeSocketURL() + let server = try makeServer(socketURL: socketURL) { request in + HostRPCResponse(id: request.id, status: 200, payload: Data(), errorCode: nil) + } + defer { server.stop() } + + let previousSigpipeHandler = Darwin.signal(SIGPIPE, SIG_IGN) + defer { _ = Darwin.signal(SIGPIPE, previousSigpipeHandler) } + + let fd = try connectToSocket(socketURL) + try writeAll(fd: fd, data: Data(#"{"id":"77777777-7777-7777-7777-777777777777","method":"status","payload":""#.utf8)) + close(fd) + + Thread.sleep(forTimeInterval: 0.05) + + let response = try call(socketURL: socketURL, request: HostRPCRequest( + id: UUID(uuidString: "88888888-8888-8888-8888-888888888888")!, + method: "status", + payload: Data() + )) + + XCTAssertEqual(response.status, 200) + XCTAssertEqual(response.payload, Data()) + } + + func testSocketPathAndParentDirectoryAreOwnerOnly() throws { + let socketURL = try makeSocketURL() + let server = try makeServer(socketURL: socketURL) { request in + HostRPCResponse(id: request.id, status: 200, payload: Data(), errorCode: nil) + } + defer { server.stop() } + + let directoryPermissions = try permissions(at: socketURL.deletingLastPathComponent()) + let socketPermissions = try permissions(at: socketURL) + + XCTAssertEqual(directoryPermissions, 0o700) + XCTAssertEqual(socketPermissions, 0o600) + } + + private func makeServer( + socketURL: URL, + maxRequestBytes: Int = 1_048_576, + handler: @escaping (HostRPCRequest) -> HostRPCResponse + ) throws -> RemoteDesktopSocketServer { + let server = RemoteDesktopSocketServer(socketURL: socketURL, maxRequestBytes: maxRequestBytes, handler: handler) + try server.start() + addTeardownBlock { server.stop() } + return server + } + + private func makeSocketURL() throws -> URL { + let base = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("codex-rds-\(UUID().uuidString.prefix(8))", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base.appendingPathComponent("remote-desktop.sock") + } + + private func createStaleSocket(at socketURL: URL) throws { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .ENOTSUP) } + defer { close(fd) } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketURL.path.utf8) + guard pathBytes.count < MemoryLayout.size(ofValue: address.sun_path) else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG), userInfo: nil) + } + + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + buffer.initializeMemory(as: UInt8.self, repeating: 0) + _ = pathBytes.withUnsafeBytes { source in + memcpy(buffer.baseAddress, source.baseAddress, pathBytes.count) + } + } + + let bindResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard Darwin.listen(fd, 1) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } + + private func call(socketURL: URL, request: HostRPCRequest) throws -> HostRPCResponse { + let data = try JSONEncoder().encode(request) + Data([0x0A]) + return try sendLine(data, to: socketURL) + } + + private func sendRawLine(_ line: String, to socketURL: URL) throws -> HostRPCResponse { + try sendLine(Data(line.utf8), to: socketURL) + } + + private func sendLine(_ line: Data, to socketURL: URL) throws -> HostRPCResponse { + let fd = try connectToSocket(socketURL) + defer { close(fd) } + + try writeAll(fd: fd, data: line) + + let responseData = try readResponseLine(fd: fd) + return try JSONDecoder().decode(HostRPCResponse.self, from: responseData) + } + + private func connectToSocket(_ socketURL: URL) throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .ENOTSUP) } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = Array(socketURL.path.utf8) + guard pathBytes.count < MemoryLayout.size(ofValue: address.sun_path) else { + close(fd) + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG), userInfo: nil) + } + + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + buffer.initializeMemory(as: UInt8.self, repeating: 0) + _ = pathBytes.withUnsafeBytes { source in + memcpy(buffer.baseAddress, source.baseAddress, pathBytes.count) + } + } + + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + let code = errno + close(fd) + throw POSIXError(POSIXErrorCode(rawValue: code) ?? .ENOTCONN) + } + + var one: Int32 = 1 + _ = setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + &one, + socklen_t(MemoryLayout.size(ofValue: one)) + ) + + return fd + } + + private func writeAll(fd: Int32, data: Data) throws { + try data.withUnsafeBytes { buffer in + guard let base = buffer.bindMemory(to: UInt8.self).baseAddress else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(EINVAL), userInfo: nil) + } + var offset = 0 + while offset < data.count { + let written = Darwin.write(fd, base.advanced(by: offset), data.count - offset) + if written < 0 { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + offset += written + } + } + } + + private func readResponseLine(fd: Int32) throws -> Data { + var buffer = [UInt8](repeating: 0, count: 4096) + var response = Data() + while true { + let count = recv(fd, &buffer, buffer.count, 0) + if count < 0 { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + if count == 0 { + break + } + response.append(contentsOf: buffer.prefix(count)) + if response.contains(0x0A) { + break + } + } + guard let newlineIndex = response.firstIndex(of: 0x0A) else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(EPROTO), userInfo: nil) + } + return response.prefix(upTo: newlineIndex) + } + + private func permissions(at url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return (attributes[.posixPermissions] as? NSNumber).map { $0.intValue } ?? -1 + } +} + +private final class LockedValue { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + self.storage = value + } + + func set(_ newValue: Value) { + lock.lock() + storage = newValue + lock.unlock() + } + + var value: Value { + lock.lock() + defer { lock.unlock() } + return storage + } +} diff --git a/Tests/CodexAccountSwitcherTests/SessionLeaseStoreTests.swift b/Tests/CodexAccountSwitcherTests/SessionLeaseStoreTests.swift new file mode 100644 index 00000000..5506b1b8 --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/SessionLeaseStoreTests.swift @@ -0,0 +1,282 @@ +import CryptoKit +import Foundation +import XCTest +@testable import CodexAccountSwitcher + +final class SessionLeaseStoreTests: XCTestCase { + func testNonceProofCannotBeReplayedToCreateOrRenewLeases() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let pairingStore = try makeTrustedPairingStore(clock: { now }) + let leaseStore = makeLeaseStore(clock: { now }, pairingStore: pairingStore) + let privateKey = try trustedPrivateKey(using: pairingStore) + + let nonce = leaseStore.issueNonce(for: "device-1") + let proof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: nonce + ) + + let lease = try leaseStore.createLease(using: proof) + XCTAssertEqual(lease.deviceId, "device-1") + + XCTAssertThrowsError(try leaseStore.renewLease(leaseID: lease.id, using: proof)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .nonceAlreadyUsed) + } + + leaseStore.endLease(leaseID: lease.id) + XCTAssertThrowsError(try leaseStore.createLease(using: proof)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .nonceAlreadyUsed) + } + } + + func testOnlyOneControllerCanHoldLeaseAtATime() throws { + let pairingStore = try makeTrustedPairingStore(clock: Date.init) + let leaseStore = makeLeaseStore(clock: Date.init, pairingStore: pairingStore) + let privateKey = try trustedPrivateKey(using: pairingStore) + + let firstNonce = leaseStore.issueNonce(for: "device-1") + let firstProof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: firstNonce + ) + _ = try leaseStore.createLease(using: firstProof) + + let secondNonce = leaseStore.issueNonce(for: "device-1") + let secondProof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: secondNonce + ) + + XCTAssertThrowsError(try leaseStore.createLease(using: secondProof)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .controllerBusy) + } + } + + func testLeaseExpiresAfterTenMinutes() throws { + var now = Date(timeIntervalSince1970: 2_000_000_000) + let pairingStore = try makeTrustedPairingStore(clock: { now }) + let leaseStore = makeLeaseStore(clock: { now }, pairingStore: pairingStore) + let privateKey = try trustedPrivateKey(using: pairingStore) + + let nonce = leaseStore.issueNonce(for: "device-1") + let proof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: nonce + ) + let lease = try leaseStore.createLease(using: proof) + + now = now.addingTimeInterval(601) + XCTAssertNil(leaseStore.activeLease()) + + XCTAssertThrowsError(try leaseStore.validateSequence(1, for: lease.id)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .leaseExpired) + } + } + + func testSequenceReplayIsRejected() throws { + let pairingStore = try makeTrustedPairingStore(clock: Date.init) + let leaseStore = makeLeaseStore(clock: Date.init, pairingStore: pairingStore) + let privateKey = try trustedPrivateKey(using: pairingStore) + + let nonce = leaseStore.issueNonce(for: "device-1") + let proof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: nonce + ) + let lease = try leaseStore.createLease(using: proof) + + try leaseStore.validateSequence(1, for: lease.id) + + XCTAssertThrowsError(try leaseStore.validateSequence(1, for: lease.id)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .sequenceReplay) + } + } + + func testFreshProofCanRenewLeaseButWrongStaleAndRevokedProofsCannot() throws { + var now = Date(timeIntervalSince1970: 2_000_000_000) + let pairingStore = try makeTrustedPairingStore(clock: { now }) + let leaseStore = makeLeaseStore(clock: { now }, pairingStore: pairingStore) + let privateKey = try trustedPrivateKey(using: pairingStore) + + let startNonce = leaseStore.issueNonce(for: "device-1") + let startProof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: startNonce + ) + let lease = try leaseStore.createLease(using: startProof) + + let wrongNonce = "nonce-wrong" + let wrongProof = try pairingStore.verifyNonce( + deviceID: "device-1", + nonce: wrongNonce, + signature: try privateKey.signature(for: Data(wrongNonce.utf8)).derRepresentation + ) + + XCTAssertThrowsError(try leaseStore.renewLease(leaseID: lease.id, using: wrongProof)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .nonceUnknown) + } + + let renewalNonce = leaseStore.issueNonce(for: "device-1") + let renewalProof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: renewalNonce + ) + now = now.addingTimeInterval(1) + let renewed = try leaseStore.renewLease(leaseID: lease.id, using: renewalProof) + XCTAssertGreaterThan(renewed.expiresAt, lease.expiresAt) + + let staleNonce = leaseStore.issueNonce(for: "device-1") + let staleProof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: staleNonce + ) + now = now.addingTimeInterval(61) + XCTAssertThrowsError(try leaseStore.createLease(using: staleProof)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .nonceExpired) + } + + let revokedNonce = leaseStore.issueNonce(for: "device-1") + let revokedProof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: revokedNonce + ) + _ = try pairingStore.revokeDevice(id: "device-1") + + XCTAssertThrowsError(try leaseStore.createLease(using: revokedProof)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .deviceRevoked) + } + XCTAssertThrowsError(try leaseStore.renewLease(leaseID: lease.id, using: revokedProof)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .deviceRevoked) + } + } + + func testValidateSequenceFailsAfterRevocationEvenForActiveLease() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let pairingStore = try makeTrustedPairingStore(clock: { now }) + let leaseStore = makeLeaseStore(clock: { now }, pairingStore: pairingStore) + let privateKey = try trustedPrivateKey(using: pairingStore, deviceID: "device-1") + + let nonce = leaseStore.issueNonce(for: "device-1") + let proof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: privateKey, + deviceID: "device-1", + nonce: nonce + ) + let lease = try leaseStore.createLease(using: proof) + + _ = try pairingStore.revokeDevice(id: "device-1") + + XCTAssertThrowsError(try leaseStore.validateSequence(1, for: lease.id)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .deviceRevoked) + } + } + + func testRevokedLeaseIsFreedForReplacementController() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let pairingStore = try makeTrustedPairingStore(clock: { now }) + let leaseStore = makeLeaseStore(clock: { now }, pairingStore: pairingStore) + let firstPrivateKey = try trustedPrivateKey(using: pairingStore, deviceID: "device-1") + let secondPrivateKey = try trustedPrivateKey(using: pairingStore, deviceID: "device-2") + + let firstNonce = leaseStore.issueNonce(for: "device-1") + let firstProof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: firstPrivateKey, + deviceID: "device-1", + nonce: firstNonce + ) + let firstLease = try leaseStore.createLease(using: firstProof) + _ = try pairingStore.revokeDevice(id: "device-1") + + XCTAssertNil(leaseStore.activeLease()) + XCTAssertThrowsError(try leaseStore.validateSequence(1, for: firstLease.id)) { error in + XCTAssertEqual(error as? RemoteDesktopSecurityError, .deviceRevoked) + } + + let secondNonce = leaseStore.issueNonce(for: "device-2") + let secondProof = try verifiedNonce( + pairingStore: pairingStore, + privateKey: secondPrivateKey, + deviceID: "device-2", + nonce: secondNonce + ) + let secondLease = try leaseStore.createLease(using: secondProof) + XCTAssertEqual(secondLease.deviceId, "device-2") + } + + private func makeTrustedPairingStore(clock: @escaping PairingStore.Clock) throws -> PairingStore { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("trusted-devices.json") + addTeardownBlock { + try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) + } + return try PairingStore(fileURL: fileURL, clock: clock) + } + + private func makeLeaseStore( + clock: @escaping SessionLeaseStore.Clock, + pairingStore: PairingStore + ) -> SessionLeaseStore { + SessionLeaseStore(clock: clock, currentTrustValidator: { deviceID in + guard let device = pairingStore.trustedDevice(id: deviceID) else { + throw RemoteDesktopSecurityError.untrustedDevice + } + guard device.revokedAt == nil else { + throw RemoteDesktopSecurityError.deviceRevoked + } + }) + } + + private func trustedPrivateKey( + using store: PairingStore, + deviceID: String = "device-1" + ) throws -> P256.Signing.PrivateKey { + let privateKey = P256.Signing.PrivateKey() + let challenge = store.issueChallenge( + deviceID: deviceID, + name: deviceID, + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Studio Mac" + ) + let token = try store.verifyChallenge( + challenge, + deviceID: deviceID, + signature: try privateKey.signature(for: Data(challenge.code.utf8)).derRepresentation + ) + _ = try store.approveDevice(using: token) + return privateKey + } + + private func verifiedNonce( + pairingStore: PairingStore, + privateKey: P256.Signing.PrivateKey, + deviceID: String, + nonce: String + ) throws -> VerifiedRemoteDesktopNonce { + try pairingStore.verifyNonce( + deviceID: deviceID, + nonce: nonce, + signature: try privateKey.signature(for: Data(nonce.utf8)).derRepresentation + ) + } +} diff --git a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift new file mode 100644 index 00000000..a7068d0f --- /dev/null +++ b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift @@ -0,0 +1,17 @@ +import XCTest +@testable import CodexAccountSwitcher + +final class SetupStatusTests: XCTestCase { + func testSetupStatusLabelsAreUserFacing() { + XCTAssertEqual(CodePilotSetupRequirement.gatewayStopped.statusLabel, "Stopped") + XCTAssertEqual(CodePilotSetupRequirement.gatewayBlockedByActiveTurn.statusLabel, "Blocked by active turn") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareOptional.statusLabel, "Optional") + } + + func testCloudflareStatusLabelsAreUserFacing() { + XCTAssertEqual(CodePilotSetupRequirement.cloudflareReady.statusLabel, "Ready") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareMissing.statusLabel, "Missing") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareNeedsConfiguration.statusLabel, "Needs setup") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareOptional.statusLabel, "Optional") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d262cec0..02c40628 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,6 +20,7 @@ Responsibilities: - Report account usage. - Report plugin and connector status. - Register notification devices. +- Provide stable health and error responses that the Mac and iOS apps can turn into recovery steps. ## iOS App @@ -46,4 +47,3 @@ The public product name is provider-neutral. Future provider support should isol - Turn execution. - Thread storage. - Connector/plugin status. - diff --git a/docs/CLOUDFLARE_SETUP.md b/docs/CLOUDFLARE_SETUP.md index bc30e1d3..495f4dd2 100644 --- a/docs/CLOUDFLARE_SETUP.md +++ b/docs/CLOUDFLARE_SETUP.md @@ -2,36 +2,57 @@ Cloudflare Tunnel is the recommended way to reach the CodePilot gateway from outside the Mac's local network. -## Requirements +## Recommended: In-App Setup -- A Cloudflare account. -- A domain or subdomain managed by Cloudflare. -- `cloudflared` installed on the Mac. +Open **CodePilot > Setup CodePilot... > Cloudflare Remote Access > Set Up Remote Access...**. -## Local Gateway +The wizard explains and can configure: -The gateway listens locally on: +- `cloudflared`, Cloudflare's local tunnel daemon. +- A Cloudflare sign-in or account creation step. +- A permanent hostname such as `codepilot.example.com`. +- A temporary TryCloudflare URL for testing without a domain. +- A macOS LaunchAgent that keeps the tunnel running. + +CodePilot does not open inbound ports. Cloudflare Tunnel makes outbound connections to Cloudflare. The iPhone app still needs the CodePilot gateway token. + +## Permanent Hostname + +Choose this for regular use. You need a Cloudflare account and a domain managed by Cloudflare. + +The wizard creates or reuses a tunnel, writes: ```text -http://127.0.0.1:18790 +~/.cloudflared/codepilot-config.yaml ``` -Never expose the gateway without the bearer token check. +and installs: -## Tunnel +```text +~/Library/LaunchAgents/io.codepilot.phone-cloudflared.plist +``` -The helper script expects a Cloudflare tunnel config that forwards a public hostname to the local gateway. +## Temporary TryCloudflare + +Choose this only for testing. Cloudflare creates a temporary `trycloudflare.com` URL. It can change and should not be treated as a permanent iPhone setup URL. + +## Manual Fallback + +Run: ```sh -scripts/install-phone-cloudflared-agent.sh +scripts/setup-cloudflare-remote-access.sh status +scripts/setup-cloudflare-remote-access.sh install-cloudflared +scripts/setup-cloudflare-remote-access.sh login +scripts/setup-cloudflare-remote-access.sh configure-permanent --hostname codepilot.example.com --tunnel-name codepilot +scripts/setup-cloudflare-remote-access.sh install-service +scripts/setup-cloudflare-remote-access.sh verify --url https://codepilot.example.com ``` -The public hostname should be entered as the iOS gateway URL. - ## Troubleshooting -- **502 from Cloudflare**: gateway is not running or tunnel cannot reach `127.0.0.1:18790`. -- **401/403**: token is missing or incorrect. -- **Connection lost**: check tunnel health and gateway LaunchAgent logs. -- **Works locally but not remotely**: verify Cloudflare hostname routing and Access policies. - +- **Homebrew missing**: install Homebrew or install `cloudflared` manually from Cloudflare. +- **Hostname not on Cloudflare**: add the domain to Cloudflare first or use TryCloudflare. +- **502 from Cloudflare**: start the CodePilot gateway and restart the Cloudflare tunnel. +- **401/403**: copy the current gateway token from the Mac app into the iPhone app. +- **Works locally but not remotely**: check the Cloudflare LaunchAgent logs in `~/Library/Logs/`. diff --git a/docs/INSTALL_IOS.md b/docs/INSTALL_IOS.md index 3958f417..76a555d0 100644 --- a/docs/INSTALL_IOS.md +++ b/docs/INSTALL_IOS.md @@ -11,10 +11,14 @@ CodePilot iOS connects to a Mac running the CodePilot gateway. ## Connection -On first launch, enter: +On first launch: -- **Gateway URL**: local or Cloudflare URL for your CodePilot gateway. -- **Token**: contents of `~/.codex-account-switcher/phone-gateway-token` on the Mac. +1. Choose **Same Network** or **Cloudflare**. +2. Enter the gateway URL. +3. Enter the token from `~/.codex-account-switcher/phone-gateway-token` on the Mac. +4. Tap **Test Connection**. + +Success means the app shows the active account from the Mac gateway. The app stores these values locally on the device. diff --git a/docs/INSTALL_MAC.md b/docs/INSTALL_MAC.md index 0eb731f8..b9a827cc 100644 --- a/docs/INSTALL_MAC.md +++ b/docs/INSTALL_MAC.md @@ -9,6 +9,8 @@ CodePilot Mac is a menu bar app. It currently builds from source with SwiftPM. - Codex installed and available as `codex`. - A local Codex login at `~/.codex/auth.json`. +Successful setup means the menu bar app shows `CodePilot`, at least one account profile exists, and the setup window marks the gateway token and gateway as ready. + ## Build ```sh @@ -47,6 +49,8 @@ For iPhone access, install the local gateway: scripts/install-phone-gateway-agent.sh ``` +The script restarts the gateway only when it can verify that no phone turn is running. Use the menu bar app's **Force Restart Gateway...** action only when recovering a stuck gateway. + The gateway listens on: ```text @@ -59,3 +63,8 @@ The bearer token is stored at: ~/.codex-account-switcher/phone-gateway-token ``` +Copy this token into the iOS app connection screen. Do not share it in issue reports or screenshots. + +## 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. diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md new file mode 100644 index 00000000..aa588976 --- /dev/null +++ b/docs/PRODUCTION_READINESS.md @@ -0,0 +1,263 @@ +# CodePilot Production Readiness Design + +## Goal + +Prepare CodePilot for a public beta where the Mac menu bar app, gateway, and iOS app can be installed, understood, troubleshot, and updated by users who are not familiar with the current local development setup. + +The release target is **both in parallel**: + +- **TestFlight-first iOS beta** for the phone app. +- **GitHub/source release** for the Mac app, gateway, docs, and build scripts. + +The work should keep the current Codex-specific implementation working while making the public product feel provider-neutral enough for future Claude Code compatibility. + +## Current State + +The project is already functionally strong: + +- SwiftPM tests pass for the Mac-side code. +- Gateway Python tests pass. +- iOS simulator build and tests pass. +- Privacy audit passes. +- OTA iOS builds are produced and verified. +- Recent features include WebRTC remote desktop, localhost URL opening through the gateway, preserved chat markup, usage status, account switching, notifications, plugin/connector visibility, file uploads, and turn steering. + +The remaining gaps are productization gaps: + +- First-run setup is still too script/path-oriented. +- Some visible copy still assumes the user understands Codex homes, bearer token files, launch agents, or Cloudflare routing. +- Error messages often expose raw technical failures instead of recovery steps. +- The Mac and iOS setup flows are not yet one coherent checklist. +- Distribution requirements are not fully documented for public beta users. +- Browser visual companion clicks from the phone did not reach the event log during planning and need reliability work or explicit fallback text. + +## Release Principles + +1. **No magic without diagnostics.** If CodePilot tries to install, restart, or connect something, the UI must show what happened and what to do next. +2. **No secret leakage.** Tokens, auth file contents, private hostnames, personal names, and account identifiers must not appear in public docs, logs intended for support, or screenshots. +3. **Safe by default.** Gateway restart should still avoid active turns unless the user explicitly forces it. Localhost proxying must remain loopback-only and short-lived. +4. **Public copy first.** User-facing text should say CodePilot, Mac, iPhone, gateway, account, connector, and remote desktop. Internal Codex names are acceptable only where they describe real provider-specific requirements. +5. **One setup mental model.** Mac setup, iOS connection, Cloudflare, permissions, and notifications should all use the same checklist vocabulary: required, optional, ready, blocked, fix. + +## Mac App Design + +The Mac menu bar app should become the primary setup and operations console. + +### Setup Checklist + +The setup window should present clear rows with status, explanation, and action: + +- Codex CLI installed. +- Codex signed in. +- CodePilot account profiles created. +- Gateway installed and running. +- Gateway token available. +- Cloudflare tunnel configured, if remote access is desired. +- Screen Recording permission granted, if Remote Desktop is used. +- Accessibility permission granted, if Remote Desktop control is used. +- APNs configured, if background turn notifications are desired. + +Each row should have: + +- A human status label such as `Ready`, `Missing`, `Needs restart`, `Blocked by active turn`, or `Optional`. +- A short explanation. +- One primary action button. +- An advanced details disclosure for paths, logs, commands, or raw script output. + +### Menu Bar Copy + +The menu bar should keep dense account/usage status, but menu items should use public product language: + +- `Setup CodePilot...` +- `Open Gateway Status...` +- `Restart Gateway` +- `Restart Gateway When Idle` +- `Force Restart Gateway...` +- `Refresh Login...` +- `Remote Desktop...` + +Force restart must be explicit because it can interrupt active phone turns. + +### Gateway Operations + +Gateway install/restart status should distinguish: + +- Gateway not installed. +- Gateway stopped. +- Gateway running. +- Gateway running old code after a deferred restart. +- Restart deferred because an active turn is running. +- Port is occupied by an unknown process. + +The Mac UI should avoid dumping raw shell output as the main result. Raw output can live behind details. + +## iOS App Design + +The iOS app should guide users from first launch to a verified connection without requiring them to understand token files. + +### Connection Wizard + +Replace the bare URL/token form with a wizard-like screen: + +1. Choose connection type: + - Same network/local URL. + - Cloudflare/public URL. +2. Enter or paste gateway URL. +3. Enter or paste token. +4. Test connection. +5. Show verified account, gateway health, and next action. + +The simple form can remain available as advanced setup. + +### Main Screen + +Improve empty and blocked states: + +- No threads: explain that threads appear after connecting to a Mac gateway and loading Codex state. +- No accounts: explain how to add accounts from Mac or iPhone. +- No usage data: show that usage is still loading or auth may need refresh. +- Gateway offline: show exact recovery steps. +- Auth stale: show a refresh-login action. +- Account switched but app-server stale: show that new turns will use the active account after the running turn finishes or after gateway restart when idle. + +### Error Messages + +Map common gateway failures into actionable copy: + +- `401/403`: token missing, wrong, or stale; show where to find/copy it. +- `502`: Cloudflare reached the hostname but the Mac gateway is unavailable. +- Network lost: distinguish transient network loss from gateway stopped. +- Stale auth: offer refresh login. +- Active-turn restart deferral: explain that CodePilot is protecting a running turn. +- Missing job/thread: reload saved messages and explain the live stream was lost. + +### Localhost URL Opening + +The newly added localhost feature should be treated as a production feature: + +- Links to `localhost`, `127.0.0.1`, and `::1` should open through the Mac gateway in an in-app browser. +- Non-local links should open normally. +- The app should explain errors such as `local service unavailable`, `session expired`, or `response too large`. + +## Gateway Design + +The gateway remains a local trusted bridge between iOS and the Mac. + +### Health Endpoint + +Expose structured health without secrets: + +- Gateway version/build time. +- Active account name. +- App-server auth sync status. +- Whether active phone turns are running. +- Notification configuration present/missing. +- Remote Desktop host status. +- Localhost proxy availability. +- Cloudflare-visible base URL, if configured, without exposing tokens. + +### Error Model + +Add or normalize error codes alongside readable messages: + +- `unauthorized` +- `gateway_unavailable` +- `app_server_unavailable` +- `active_turn_running` +- `auth_stale` +- `account_unavailable` +- `thread_not_found` +- `job_not_found` +- `local_web_invalid_target` +- `local_web_unavailable` +- `remote_desktop_permission_missing` + +iOS and Mac can then map errors to stable recovery text without parsing arbitrary strings. + +### Localhost Proxy Boundary + +The local-web proxy must stay constrained: + +- Only `http` and `https`. +- Only loopback hosts. +- Short-lived random session IDs. +- Bounded response size. +- No bearer token in proxied page URLs. +- Root-relative links rewritten through the session path. + +## Distribution Design + +### TestFlight + +The iOS release should include: + +- Correct bundle identity for updating the existing installed app. +- Current display name `CodePilot`. +- Beta notes explaining gateway requirement. +- Privacy text describing local gateway, uploads, notifications, and remote desktop. +- Internal tester/audience configuration. +- A repeatable build/upload path using existing Fastlane or asc tooling. + +### GitHub/Source Release + +The public repository should include: + +- README that explains what CodePilot is, current Codex support, and future provider-neutral intent. +- Mac install guide. +- iOS install guide. +- Cloudflare setup guide. +- Security and privacy guide. +- License/NOTICE/commercial terms. +- Troubleshooting guide. +- Release checklist. + +Public docs should not contain personal hostnames, personal account names, private email addresses, private Apple team IDs, tokens, or machine-specific paths except generic examples under `~`. + +## Visual Companion Reliability + +During planning, browser clicks from the phone did not create an `events` file in the companion state directory. This should be tracked as release-prep tooling work because the visual companion is being used to make product decisions. + +Required behavior: + +- A click visibly changes the selected option in the browser. +- The server records the click in `state/events`. +- The page shows “selection received” after the event is acknowledged. +- Each visual page includes fallback text telling the user to reply in chat if selection does not register. + +Until fixed, terminal replies remain the source of truth. + +## Testing And Verification + +Every implementation pass should run: + +- `swift test` +- `python3 -m unittest test_codex_phone_gateway test_remote_desktop_gateway` from `gateway/` +- `xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build` +- iOS unit tests on the configured simulator +- `scripts/privacy-audit.sh` +- OTA build if any iOS app files changed + +Manual checks: + +- Fresh Mac app setup window with missing dependencies. +- Gateway restart when idle. +- Gateway restart deferred while a turn is running. +- Force restart warning path. +- Fresh iOS connection wizard. +- Cloudflare 502 messaging. +- Stale auth refresh path. +- Localhost URL opening through the gateway. + +## Non-Goals For This Pass + +- Full Claude Code compatibility. +- A paid licensing backend. +- A fully hosted cloud service. +- Replacing the current Codex app-server integration. +- Rewriting the whole iOS app architecture. + +## Open Decisions + +1. Whether Mac public distribution is initially source-build only or signed/notarized binary as well. +2. Whether TestFlight release notes should call the app beta/private preview or public beta. +3. Whether Cloudflare setup should remain guide-driven or become a guided Mac setup flow using `cloudflared`. diff --git a/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md b/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..58362bfe --- /dev/null +++ b/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,543 @@ +# CodePilot Production Readiness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make CodePilot ready for a public beta across TestFlight and GitHub/source distribution in parallel. + +**Architecture:** Implement a stable gateway status/error contract first, then consume it from the Mac menu bar app and iOS app. Keep release documentation and verification scripts close to the behavior they describe, and avoid public docs or support diagnostics leaking machine-specific or account-specific secrets. + +**Tech Stack:** SwiftPM/AppKit/SwiftUI for the Mac app, SwiftUI/UIKit/WebKit for iOS, Python `http.server` gateway, existing shell scripts, OTA build server, Fastlane/App Store Connect tooling. + +--- + +## File Structure + +- Modify `gateway/codex_phone_gateway.py`: add structured health fields and normalized error responses. +- Modify `gateway/test_codex_phone_gateway.py`: add contract tests for health, common errors, and local-web proxy errors. +- Modify `Sources/CodexAccountSwitcher/main.swift`: add setup/status model and safer gateway operation labels. +- Modify `Tests/CodexAccountSwitcherTests/*.swift`: add Mac-side tests for setup state and restart wording where testable. +- Modify `ios/CodexPhone/CodexPhone/CodexPhoneApp.swift`: add iOS connection wizard, status display, and stable error mapping inside the existing large SwiftUI file unless a task splits focused subviews. +- Modify `ios/CodexPhone/CodexPhoneTests/*.swift`: add tests for error mapping and connection-state decoding. +- Modify `docs/README.md` if it exists, otherwise `README.md`: keep the public overview current. +- Modify `docs/INSTALL_MAC.md`, `docs/INSTALL_IOS.md`, `docs/CLOUDFLARE_SETUP.md`, `docs/SECURITY.md`, and `docs/ARCHITECTURE.md`: production copy and public setup instructions. +- Create `docs/TROUBLESHOOTING.md`: user-facing recovery guide. +- Create `docs/RELEASE_CHECKLIST.md`: repeatable TestFlight and GitHub/source checklist. +- Modify `.superpowers/brainstorm/...` only for local planning visuals; do not commit those files. + +## Task 1: Gateway Health And Error Contract + +**Files:** +- Modify: `gateway/codex_phone_gateway.py` +- Modify: `gateway/test_codex_phone_gateway.py` + +- [ ] **Step 1: Write failing tests for health shape** + +Add tests that assert `/api/health` returns public-safe fields without token values: + +```python +def test_health_exposes_public_gateway_status(self): + status, payload = self.request_json("GET", "/api/health") + self.assertEqual(status, 200) + self.assertIn("gateway", payload) + self.assertIn("accounts", payload) + self.assertIn("notifications", payload) + self.assertIn("remoteDesktop", payload) + self.assertIn("localWeb", payload) + self.assertNotIn("token", json.dumps(payload).lower()) +``` + +- [ ] **Step 2: Write failing tests for normalized errors** + +Add tests that verify known failures include `error.code`, `error.message`, and `error.recovery`: + +```python +def test_error_payload_has_stable_code_and_recovery(self): + status, payload = self.request_json("POST", "/api/local-web/open", body={"url": "file:///etc/passwd"}) + self.assertEqual(status, 400) + self.assertEqual(payload["error"]["code"], "local_web_invalid_target") + self.assertIn("localhost", payload["error"]["recovery"].lower()) +``` + +- [ ] **Step 3: Run gateway tests and confirm failure** + +Run: + +```sh +cd gateway +python3 -m unittest test_codex_phone_gateway +``` + +Expected: the new tests fail because the health/error contract is incomplete. + +- [ ] **Step 4: Add helper functions** + +In `gateway/codex_phone_gateway.py`, add: + +```python +def error_payload(code: str, message: str, recovery: str, details: dict | None = None) -> dict: + payload = {"error": {"code": code, "message": message, "recovery": recovery}} + if details: + payload["error"]["details"] = details + return payload + + +def json_error(handler, status: int, code: str, message: str, recovery: str, details: dict | None = None): + json_response(handler, status, error_payload(code, message, recovery, details)) +``` + +- [ ] **Step 5: Implement structured health** + +Add a `GatewayState.public_health()` method that returns: + +```python +{ + "gateway": {"version": read_marker(self.switcher_home / "gateway-version", "dev"), "running": True}, + "accounts": {"active": read_marker(self.active_account_marker(), ""), "auth": self.app_server_auth_status()}, + "turns": {"running": self.has_running_jobs()}, + "notifications": {"configured": bool(APNsPushNotifier.from_environment())}, + "remoteDesktop": self.remote_desktop.public_status() if self.remote_desktop else {"available": False}, + "localWeb": {"available": True, "sessionSeconds": LOCAL_WEB_SESSION_TIMEOUT_SECONDS}, +} +``` + +Keep secrets, tokens, full auth paths, email addresses, and private hostnames out of the response. + +- [ ] **Step 6: Replace raw common error responses** + +Update local-web, unauthorized, thread-not-found, job-not-found, stale-auth, and app-server unavailable paths to call `json_error(...)` with these codes: + +```python +"unauthorized" +"gateway_unavailable" +"app_server_unavailable" +"active_turn_running" +"auth_stale" +"account_unavailable" +"thread_not_found" +"job_not_found" +"local_web_invalid_target" +"local_web_unavailable" +"remote_desktop_permission_missing" +``` + +- [ ] **Step 7: Run gateway tests** + +Run: + +```sh +cd gateway +python3 -m unittest test_codex_phone_gateway test_remote_desktop_gateway +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```sh +git add gateway/codex_phone_gateway.py gateway/test_codex_phone_gateway.py +git commit -m "feat: expose stable gateway health and errors" +``` + +## Task 2: Mac Setup And Gateway Operations UI + +**Files:** +- Modify: `Sources/CodexAccountSwitcher/main.swift` +- Modify: `Tests/CodexAccountSwitcherTests/ActiveTurnTrackerTests.swift` or create `Tests/CodexAccountSwitcherTests/SetupStatusTests.swift` + +- [ ] **Step 1: Write tests for setup labels** + +Create a small pure model test: + +```swift +func testSetupStatusLabelsAreUserFacing() { + XCTAssertEqual(SetupRequirement.gatewayStopped.statusLabel, "Stopped") + XCTAssertEqual(SetupRequirement.gatewayBlockedByActiveTurn.statusLabel, "Blocked by active turn") + XCTAssertEqual(SetupRequirement.cloudflareOptional.statusLabel, "Optional") +} +``` + +- [ ] **Step 2: Run Swift tests and confirm failure** + +Run: + +```sh +swift test --filter SetupStatusTests +``` + +Expected: failure because the setup model does not exist. + +- [ ] **Step 3: Add setup model** + +Add a small enum/model near existing settings types: + +```swift +enum SetupRequirement: Equatable { + case codexCLIInstalled + case codexSignedIn + case profilesCreated + case gatewayRunning + case gatewayStopped + case gatewayBlockedByActiveTurn + case cloudflareOptional + case screenRecordingMissing + case accessibilityMissing + case notificationsOptional + + var statusLabel: String { + switch self { + case .codexCLIInstalled, .codexSignedIn, .profilesCreated, .gatewayRunning: + return "Ready" + case .gatewayStopped: + return "Stopped" + case .gatewayBlockedByActiveTurn: + return "Blocked by active turn" + case .cloudflareOptional, .notificationsOptional: + return "Optional" + case .screenRecordingMissing, .accessibilityMissing: + return "Missing" + } + } +} +``` + +- [ ] **Step 4: Replace menu copy** + +Update menu titles to public CodePilot wording: + +```swift +"Setup CodePilot..." +"Open Gateway Status..." +"Restart Gateway" +"Restart Gateway When Idle" +"Force Restart Gateway..." +"Refresh Login..." +"Remote Desktop..." +``` + +Keep any destructive or interrupting action behind explicit confirmation. + +- [ ] **Step 5: Run Mac tests** + +Run: + +```sh +swift test +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```sh +git add Sources/CodexAccountSwitcher/main.swift Tests/CodexAccountSwitcherTests +git commit -m "feat: clarify CodePilot setup and gateway operations" +``` + +## Task 3: iOS Connection Wizard And Error Mapping + +**Files:** +- Modify: `ios/CodexPhone/CodexPhone/CodexPhoneApp.swift` +- Modify: `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift` or create `ios/CodexPhone/CodexPhoneTests/GatewayErrorMappingTests.swift` + +- [ ] **Step 1: Write error mapping tests** + +Add tests for stable gateway errors: + +```swift +func testGatewayErrorRecoveryCopy() { + let error = GatewayErrorPayload.ErrorBody( + code: "gateway_unavailable", + message: "Gateway unavailable", + recovery: "Restart CodePilot Gateway on your Mac." + ) + XCTAssertEqual(GatewayErrorPresenter.title(for: error), "Gateway unavailable") + XCTAssertTrue(GatewayErrorPresenter.recovery(for: error).contains("Restart")) +} +``` + +- [ ] **Step 2: Run iOS tests and confirm failure** + +Run: + +```sh +xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -destination 'platform=iOS Simulator,name=iPhone 16' test +``` + +Expected: failure because the presenter does not exist. + +- [ ] **Step 3: Add Codable error type and presenter** + +Add: + +```swift +struct GatewayErrorPayload: Decodable, Equatable { + struct ErrorBody: Decodable, Equatable { + let code: String + let message: String + let recovery: String + } + let error: ErrorBody +} + +enum GatewayErrorPresenter { + static func title(for error: GatewayErrorPayload.ErrorBody) -> String { error.message } + static func recovery(for error: GatewayErrorPayload.ErrorBody) -> String { error.recovery } +} +``` + +- [ ] **Step 4: Replace generic network messages** + +Where the app currently displays raw request failures, try to decode `GatewayErrorPayload` first and show the recovery text. Keep raw details behind a disclosure or copied diagnostics only. + +- [ ] **Step 5: Add connection wizard state** + +Add a connection setup enum and persist existing keys: + +```swift +enum GatewayConnectionKind: String, CaseIterable, Identifiable { + case local + case cloudflare + var id: String { rawValue } +} +``` + +The first-run screen should ask for connection type, gateway URL, token, then test `/api/health`. + +- [ ] **Step 6: Run iOS tests and build** + +Run: + +```sh +xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build +xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -destination 'platform=iOS Simulator,name=iPhone 16' test +``` + +Expected: build and tests pass. + +- [ ] **Step 7: Run required OTA build** + +Because this task changes `ios/CodexPhone/`, run: + +```sh +curl -fsS -X POST http://127.0.0.1:8787/codexphone/api/build +curl -fsS http://127.0.0.1:8787/codexphone/api/status +``` + +Wait until `current.state == "complete"`, then verify the manifest and IPA from `https://ota.sprenkeling.co` return HTTP 200. + +- [ ] **Step 8: Commit** + +```sh +git add ios/CodexPhone/CodexPhone ios/CodexPhone/CodexPhoneTests +git commit -m "feat: add CodePilot iOS connection setup" +``` + +## Task 4: Public Docs, Troubleshooting, And Privacy Sweep + +**Files:** +- Modify: `README.md` +- Modify: `docs/INSTALL_MAC.md` +- Modify: `docs/INSTALL_IOS.md` +- Modify: `docs/CLOUDFLARE_SETUP.md` +- Modify: `docs/SECURITY.md` +- Modify: `docs/ARCHITECTURE.md` +- Create: `docs/TROUBLESHOOTING.md` +- Create: `docs/RELEASE_CHECKLIST.md` + +- [ ] **Step 1: Add troubleshooting doc** + +Create `docs/TROUBLESHOOTING.md` with sections for: + +```markdown +# Troubleshooting + +## iPhone Shows 502 + +Cloudflare reached your hostname, but your Mac gateway was not reachable. Open CodePilot on the Mac and choose `Restart Gateway When Idle`. + +## iPhone Shows 401 Or 403 + +The saved gateway token is missing or wrong. Copy the current token from CodePilot on the Mac and update the iPhone connection. + +## Login Is Stale + +Use `Refresh Login` for the affected account. CodePilot will keep existing turns running and apply the refreshed account to future turns. +``` + +- [ ] **Step 2: Add release checklist** + +Create `docs/RELEASE_CHECKLIST.md` with: + +```markdown +# Release Checklist + +- [ ] `swift test` +- [ ] Gateway unit tests +- [ ] iOS simulator build +- [ ] iOS simulator tests +- [ ] Privacy audit +- [ ] OTA build if iOS changed +- [ ] TestFlight upload if shipping beta +- [ ] Public docs checked for personal names, emails, tokens, hostnames, and machine paths +``` + +- [ ] **Step 3: Update install docs** + +Update Mac/iOS/Cloudflare docs so the first screen tells users what they need, what is optional, and what success looks like. + +- [ ] **Step 4: Run privacy audit** + +Run: + +```sh +scripts/privacy-audit.sh +``` + +Expected: pass with no private names, emails, tokens, Apple team IDs, or machine-specific hostnames in public docs. + +- [ ] **Step 5: Commit** + +```sh +git add README.md docs +git commit -m "docs: prepare CodePilot public beta guides" +``` + +## Task 5: Visual Companion Command Reliability + +**Files:** +- Modify the visual companion page generator or local HTML under `.superpowers/brainstorm/...` only if this tooling is intentionally kept out of the product. +- If production code owns the companion page, modify the owning source file instead. + +- [ ] **Step 1: Reproduce missed click** + +Open the visual page from the iPhone, click an option, and check: + +```sh +rtk ls .superpowers/brainstorm/*/state +rtk read .superpowers/brainstorm/*/state/events --tail-lines 20 +``` + +Expected current bug: the click visibly changes the page but no event reaches `state/events`. + +- [ ] **Step 2: Add acknowledgement behavior** + +Update the page script so every click: + +```javascript +await fetch("/event", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({kind: "selection", value}) +}); +document.querySelector("[data-selection-status]").textContent = "Selection received"; +``` + +- [ ] **Step 3: Add fallback copy** + +Each visual companion page should include: + +```html +

If tapping a choice does not show “Selection received”, reply in chat with the option name.

+``` + +- [ ] **Step 4: Verify from phone** + +Click from the iPhone and confirm `state/events` contains the selected value. + +- [ ] **Step 5: Commit only if source-owned** + +If changes are in tracked source: + +```sh +git add +git commit -m "fix: acknowledge visual companion selections" +``` + +If changes are only under `.superpowers/`, do not commit them. + +## Task 6: Distribution Verification In Parallel + +**Files:** +- Modify: `ios/CodexPhone/fastlane/Fastfile` only if required. +- Modify: `ios/CodexPhone/fastlane/README.md` only if instructions changed. +- Modify: `scripts/build-app.sh` only if Mac build output is wrong. + +- [ ] **Step 1: Verify bundle identity updates the existing app** + +Inspect the iOS bundle ID in `ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj` and confirm it matches the already-installed app identity, not a new CodePilot-only identity. + +- [ ] **Step 2: Build iOS for simulator** + +Run: + +```sh +xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Run OTA build if any iOS files changed** + +Run: + +```sh +curl -fsS -X POST http://127.0.0.1:8787/codexphone/api/build +``` + +Poll: + +```sh +curl -fsS http://127.0.0.1:8787/codexphone/api/status +``` + +Expected: `current.state` becomes `complete`. + +- [ ] **Step 4: Verify OTA assets** + +Run: + +```sh +curl -I https://ota.sprenkeling.co/codexphone/manifest.plist +curl -I https://ota.sprenkeling.co/codexphone/CodePilot.ipa +``` + +Expected: HTTP 200 or the exact current generated manifest/IPA paths return HTTP 200. + +- [ ] **Step 5: Verify GitHub/source package state** + +Run: + +```sh +git status --short +scripts/privacy-audit.sh +``` + +Expected: only intentional untracked scratch files remain; privacy audit passes. + +- [ ] **Step 6: Commit** + +```sh +git add ios/CodexPhone/fastlane scripts docs README.md +git commit -m "chore: verify CodePilot beta distribution" +``` + +## Final Verification + +- [ ] Run `swift test`. +- [ ] Run `cd gateway && python3 -m unittest test_codex_phone_gateway test_remote_desktop_gateway`. +- [ ] Run iOS simulator build. +- [ ] Run iOS simulator tests. +- [ ] Run `scripts/privacy-audit.sh`. +- [ ] Run OTA build if iOS files changed. +- [ ] Confirm `.superpowers/` scratch files are not committed. +- [ ] Confirm the implementation branch has one focused commit per task. + +## Spec Coverage Check + +- Gateway health and error model: Task 1. +- Mac setup/status and safe gateway operations: Task 2. +- iOS first-run setup and recovery UX: Task 3. +- Public docs, install flow, Cloudflare guidance, privacy language: Task 4. +- Visual companion click reliability: Task 5. +- TestFlight and GitHub/source release target in parallel: Task 6. +- Localhost URL opening is already implemented; Task 1 and Task 3 keep its status/error behavior production-ready. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 00000000..8ee9ae91 --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,12 @@ +# Release Checklist + +- [ ] `swift test` +- [ ] `cd gateway && python3 -m unittest test_codex_phone_gateway test_remote_desktop_gateway` +- [ ] `xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build` +- [ ] iOS test target compile or simulator tests +- [ ] `scripts/privacy-audit.sh` +- [ ] OTA build if iOS app files changed +- [ ] Public manifest and IPA return HTTP 200 from the OTA domain +- [ ] TestFlight upload if shipping a beta build +- [ ] Public docs checked for personal names, private email addresses, tokens, hostnames, Apple team IDs, and machine-specific paths +- [ ] `.superpowers/` scratch files are not committed diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b0527679..07f7b718 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -23,6 +23,8 @@ Recommended: - Rotate the token if a device is lost. - Restrict Cloudflare access where practical. +The gateway health response is intentionally public-safe, but it is still operational metadata. Treat the gateway URL and token as private. + ## Account Switching CodePilot swaps local provider auth files. It waits for active turns to finish before automatic switching, but users should still avoid manually editing auth files while turns are running. @@ -31,3 +33,4 @@ CodePilot swaps local provider auth files. It waits for active turns to finish b Do not include auth files, bearer tokens, logs containing tokens, or uploaded private files in issue reports. +Before sharing logs, search for account names, email addresses, hostnames, tokens, Apple team identifiers, and local file paths. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 00000000..8e278f14 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,45 @@ +# Troubleshooting + +## iPhone Shows 502 + +Cloudflare reached your hostname, but the Mac gateway was not reachable. Open CodePilot on the Mac and choose **Restart Gateway When Idle**. + +If the Mac is asleep, offline, or the Cloudflare tunnel is stopped, bring the Mac online and restart the tunnel. + +## Cloudflare Setup Fails + +Open **Setup CodePilot... > Cloudflare Remote Access** and review the details for the failed step. + +- If `cloudflared` is missing, run the install step or install it manually from Cloudflare. +- If Homebrew is missing, install Homebrew or use Cloudflare's manual package. +- If login fails, rerun **Sign In or Create Account**. +- If DNS routing fails, confirm the hostname belongs to a Cloudflare-managed domain. +- If public verification fails, confirm the local gateway is running and restart the tunnel. + +## iPhone Shows 401 Or 403 + +The saved gateway token is missing or wrong. Copy the current token from CodePilot on the Mac and update the iPhone connection. + +If the token was rotated, every connected iPhone must be updated. + +## Login Is Stale + +Use **Refresh Login** for the affected account. CodePilot keeps existing turns running and applies the refreshed account to future turns. + +## Gateway Restart Is Deferred + +CodePilot avoids restarting the gateway while a phone turn is running. Wait for the turn to finish, then choose **Restart Gateway When Idle** again. + +Use **Force Restart Gateway...** only when the gateway is stuck and interrupting an active phone turn is acceptable. + +## Threads Or Messages Look Stale + +Pull to refresh the thread list. If a live stream was interrupted, open the thread again after the turn finishes so saved messages can reload from the Mac gateway. + +## Remote Desktop Controls Do Not Work + +Open the CodePilot setup window on the Mac and confirm Screen Recording and Accessibility permissions are granted. Restart CodePilot after changing macOS privacy permissions. + +## Localhost Link Does Not Open + +The Mac-side web server must still be running on `localhost`, `127.0.0.1`, or `::1`. Reopen the link from the iOS app if the local-web session expired. diff --git a/docs/operations/continuous-agents.md b/docs/operations/continuous-agents.md new file mode 100644 index 00000000..1a0f5290 --- /dev/null +++ b/docs/operations/continuous-agents.md @@ -0,0 +1,27 @@ +# CodePilot Continuous Agents + +The preferred implementation is Codex recurring automations attached to this thread, because those can escalate in the same conversation. + +On 2026-07-01, the automation creation backend hung twice while creating the first job. No automation files were created. + +This repository therefore includes a disabled local LaunchAgent fallback: + +```sh +scripts/install-codepilot-local-agents.sh +``` + +The installer prints the limitation and exits unless called with: + +```sh +scripts/install-codepilot-local-agents.sh --install +``` + +## Limitation + +Local LaunchAgents cannot ping the current Codex thread. They write: + +- logs to `~/Library/Logs/CodePilotAgents` +- escalation notes to `ops/agents/escalations` + +Use this fallback only if file/GitHub-based escalation is acceptable. Otherwise, wait until Codex recurring automation creation works and create the jobs from `docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md`. + diff --git a/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md b/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md new file mode 100644 index 00000000..27bb2647 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md @@ -0,0 +1,646 @@ +# Secure Remote Desktop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add paired, Face-ID-authorized, low-latency remote viewing and full control of an unlocked Mac from the CodePilot iOS app. + +**Architecture:** A native Swift host inside the CodePilot menu-bar process owns ScreenCaptureKit, WebRTC, input injection, device trust, and controller leases. The Python gateway remains the bearer-authenticated public signaling boundary and forwards remote-desktop operations to the native host over an owner-only Unix socket. The iOS app holds a Secure Enclave identity, negotiates WebRTC through the gateway, and renders a full-screen multi-display controller. + +**Tech Stack:** Swift 5.9, AppKit, SwiftUI, ScreenCaptureKit, VideoToolbox, Security/Secure Enclave, LocalAuthentication, Core Graphics, Google WebRTC M149 binary XCFramework, Python 3 `http.server`, Unix domain sockets, XCTest/Swift Testing, Python `unittest`, Cloudflare Tunnel and TURN. + +--- + +## File Structure + +### Mac host + +- `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift`: Codable wire contracts shared by native host modules. +- `Sources/CodexAccountSwitcher/RemoteDesktop/PairingStore.swift`: pairing challenges, trusted public keys, revocation, signature verification. +- `Sources/CodexAccountSwitcher/RemoteDesktop/SessionLeaseStore.swift`: one-controller lease state, nonce replay protection, sequence validation. +- `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopAuditLog.swift`: privacy-safe append-only JSONL audit events. +- `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopSocketServer.swift`: owner-only Unix socket request dispatcher. +- `Sources/CodexAccountSwitcher/RemoteDesktop/ScreenCaptureService.swift`: ScreenCaptureKit display enumeration and frame capture. +- `Sources/CodexAccountSwitcher/RemoteDesktop/InputInjector.swift`: coordinate conversion and Core Graphics input posting. +- `Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift`: WebRTC peer, video source, data channel, ICE lifecycle. +- `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift`: session lifecycle and component orchestration. +- `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift`: permissions, pairing QR, trusted devices, active session, audit UI. +- `Sources/CodexAccountSwitcher/main.swift`: menu wiring and coordinator startup only. + +### Gateway + +- `gateway/remote_desktop_gateway.py`: validated public API, native-host socket client, signaling relay, TURN credential provider. +- `gateway/test_remote_desktop_gateway.py`: API, replay, expiry, error, and redaction tests. +- `gateway/codex_phone_gateway.py`: route registration and `GatewayState` composition only. + +### iOS + +- `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift`: iOS wire contracts and display geometry. +- `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDeviceIdentity.swift`: Secure Enclave key and signed challenge operations. +- `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift`: gateway pairing, lease, signaling, clipboard, display, and session calls. +- `ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift`: WebRTC peer and data-channel lifecycle. +- `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopSession.swift`: observable session state and reconnect policy. +- `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift`: full-screen renderer and status overlays. +- `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteControlBar.swift`: keyboard, shortcuts, clipboard, files, mode, and disconnect controls. +- `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteInputMapper.swift`: gesture-to-wire-event conversion. +- `ios/CodexPhone/CodexPhone/CodexPhoneApp.swift`: navigation entry point only. +- `ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj`: source files, WebRTC package product, usage descriptions, test target. +- `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift`: identity, mapping, sequence, and lifecycle tests. + +## Task 1: Add WebRTC Dependencies And Wire Contracts + +**Files:** +- Modify: `Package.swift` +- Modify: `ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj` +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift` +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift` +- Create: `Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift` +- Create: `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift` + +- [ ] **Step 1: Add failing Codable compatibility tests** + +Define one canonical fixture in both test suites and assert it decodes to matching fields: + +```swift +let fixture = #"{"sessionId":"s1","sequence":4,"kind":"pointer","x":0.25,"y":0.75,"button":0,"keyCode":null,"text":null,"deltaX":null,"deltaY":null}"#.data(using: .utf8)! +let event = try JSONDecoder().decode(RemoteInputEvent.self, from: fixture) +#expect(event.sessionId == "s1") +#expect(event.sequence == 4) +#expect(event.kind == .pointer) +#expect(event.x == 0.25) +``` + +- [ ] **Step 2: Run tests and verify the missing-type failure** + +Run: `rtk err swift test --filter RemoteDesktopModelsTests` + +Expected: FAIL because `RemoteInputEvent` does not exist. + +- [ ] **Step 3: Add WebRTC M149 and focused model types** + +Add package dependency `https://github.com/stasel/WebRTC.git` exact version `149.0.0` and product `WebRTC` to the Mac executable and iOS app. Add an iOS XCTest target. Define identical Codable contracts on both clients: + +```swift +enum RemoteInputKind: String, Codable { + case pointer, buttonDown, buttonUp, scroll, keyDown, keyUp, text +} + +struct RemoteInputEvent: Codable, Equatable { + let sessionId: String + let sequence: UInt64 + let kind: RemoteInputKind + let x: Double? + let y: Double? + let button: Int? + let keyCode: UInt16? + let text: String? + let deltaX: Double? + let deltaY: Double? +} + +struct RemoteDisplay: Codable, Equatable, Identifiable { + let id: UInt32 + let name: String + let pixelWidth: Int + let pixelHeight: Int + let scale: Double + let rotation: Double +} +``` + +Also define pairing challenge, trusted device, lease, SDP, ICE candidate, clipboard request, and audit-event response types. Do not put capture, crypto, or UI behavior in model files. + +- [ ] **Step 4: Verify both build graphs resolve WebRTC** + +Run: `rtk err swift test --filter RemoteDesktopModelsTests` + +Run: `rtk err xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -sdk iphonesimulator -configuration Debug CODE_SIGNING_ALLOWED=NO build` + +Expected: both commands pass and Xcode resolves `WebRTC` at `149.0.0`. + +- [ ] **Step 5: Commit** + +```bash +git add Package.swift Package.resolved Sources/CodexAccountSwitcher/RemoteDesktop Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift ios/CodexPhone/CodexPhone.xcodeproj ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift ios/CodexPhone/CodexPhoneTests +git commit -m "build: add remote desktop wire contracts" +``` + +## Task 2: Implement Native Device Trust And Controller Leases + +**Files:** +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/PairingStore.swift` +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/SessionLeaseStore.swift` +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopAuditLog.swift` +- Create: `Tests/CodexAccountSwitcherTests/PairingStoreTests.swift` +- Create: `Tests/CodexAccountSwitcherTests/SessionLeaseStoreTests.swift` + +- [ ] **Step 1: Write failing security-state tests** + +Cover challenge single use and two-minute expiry, P-256 signature verification, revoked devices, nonce replay, one-controller exclusion, lease expiry, sequence replay, and redacted audit encoding: + +```swift +@Test func reusedNonceIsRejected() throws { + let store = SessionLeaseStore(clock: { Date(timeIntervalSince1970: 100) }) + let nonce = try store.issueNonce(for: "device-1") + _ = try store.createLease(deviceID: "device-1", nonce: nonce, signatureIsValid: true) + #expect(throws: RemoteDesktopSecurityError.nonceAlreadyUsed) { + try store.createLease(deviceID: "device-1", nonce: nonce, signatureIsValid: true) + } +} +``` + +- [ ] **Step 2: Confirm tests fail before implementation** + +Run: `rtk err swift test --filter PairingStoreTests` + +Run: `rtk err swift test --filter SessionLeaseStoreTests` + +Expected: FAIL with missing stores. + +- [ ] **Step 3: Implement persistent trust and ephemeral leases** + +Use `P256.Signing.PublicKey` from CryptoKit and atomic owner-only JSON persistence under `~/.codepilot/remote-desktop/trusted-devices.json`. Pairing records contain no private material: + +```swift +struct TrustedRemoteDevice: Codable, Identifiable { + let id: String + var name: String + let publicKeyRawRepresentation: Data + let approvedAt: Date + var revokedAt: Date? +} + +func verify(signature: Data, message: Data, deviceID: String) throws { + guard let device = devices[deviceID], device.revokedAt == nil else { + throw RemoteDesktopSecurityError.untrustedDevice + } + let key = try P256.Signing.PublicKey(rawRepresentation: device.publicKeyRawRepresentation) + let signature = try P256.Signing.ECDSASignature(derRepresentation: signature) + guard key.isValidSignature(signature, for: message) else { + throw RemoteDesktopSecurityError.invalidSignature + } +} +``` + +Keep leases and used nonces in memory. Default nonce TTL is 60 seconds; lease TTL is 10 minutes with explicit renewal after another signed nonce. Audit JSONL must exclude keystrokes, clipboard values, screen contents, tokens, and file contents. + +- [ ] **Step 4: Run security-state tests** + +Run: `rtk err swift test --filter PairingStoreTests` + +Run: `rtk err swift test --filter SessionLeaseStoreTests` + +Expected: PASS, including expiry and replay cases under injected clocks. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/CodexAccountSwitcher/RemoteDesktop Tests/CodexAccountSwitcherTests +git commit -m "feat: add remote desktop device trust" +``` + +## Task 3: Add Owner-Only Native Host IPC + +**Files:** +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopSocketServer.swift` +- Create: `gateway/remote_desktop_gateway.py` +- Create: `gateway/test_remote_desktop_gateway.py` +- Modify: `Sources/CodexAccountSwitcher/main.swift` + +- [ ] **Step 1: Write failing socket permission and request tests** + +Use a temporary socket and assert mode `0600`, peer requests are length-bounded, unknown methods fail closed, and malformed JSON cannot terminate the server. In Python, test connection-unavailable mapping separately from HTTP routes. + +```python +def test_native_client_rejects_oversized_response(self): + client = NativeRemoteHostClient(self.socket_path, max_response_bytes=128) + with self.assertRaises(RemoteDesktopHostError): + client.call("status", {}, fake_response=b"x" * 129) +``` + +- [ ] **Step 2: Run targeted tests and verify failure** + +Run: `rtk err swift test --filter RemoteDesktopSocketServerTests` + +Run: `rtk err python3 -m unittest gateway.test_remote_desktop_gateway.NativeRemoteHostClientTests -v` + +Expected: FAIL with missing socket server/client types. + +- [ ] **Step 3: Implement framed local RPC** + +Use a Unix socket at `~/.codepilot/remote-desktop/host.sock`, create its parent with `0700`, set the socket to `0600`, and use a four-byte big-endian length prefix followed by JSON. Cap request and response bodies at 1 MiB. The request envelope is: + +```swift +struct HostRPCRequest: Codable { + let id: UUID + let method: String + let payload: Data +} + +struct HostRPCResponse: Codable { + let id: UUID + let status: Int + let payload: Data? + let errorCode: String? +} +``` + +Allowlist methods explicitly: `status`, `pairing.start`, `pairing.complete`, `devices.list`, `devices.revoke`, `session.nonce`, `session.start`, `session.signal`, `session.end`, `session.display`, `session.clipboard`, and `audit.list`. + +- [ ] **Step 4: Wire host startup without changing account-switch behavior** + +Create the coordinator and socket server after `NSApplication` delegate startup; on failure, log remote desktop as unavailable while leaving account switching and gateway features operational. + +- [ ] **Step 5: Run focused and full tests** + +Run: `rtk err swift test` + +Run: `rtk err python3 -m unittest gateway.test_remote_desktop_gateway -v` + +Expected: all tests pass and the socket test reports owner-only permissions. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/CodexAccountSwitcher gateway/remote_desktop_gateway.py gateway/test_remote_desktop_gateway.py +git commit -m "feat: add private remote host channel" +``` + +## Task 4: Expose Pairing, Lease, And Signaling APIs + +**Files:** +- Modify: `gateway/remote_desktop_gateway.py` +- Modify: `gateway/codex_phone_gateway.py` +- Modify: `gateway/test_remote_desktop_gateway.py` + +- [ ] **Step 1: Write failing authenticated route tests** + +Test every endpoint without bearer auth, with host unavailable, malformed identifiers, expired pairing, signature rejection, replay, controller conflict, and successful forwarding. Required routes: + +```text +POST /api/remote/pairing/start +POST /api/remote/pairing/complete +GET /api/remote/devices +POST /api/remote/devices/{id}/revoke +POST /api/remote/sessions/nonce +POST /api/remote/sessions +POST /api/remote/sessions/{id}/signal +GET /api/remote/sessions/{id}/signal?after={sequence} +POST /api/remote/sessions/{id}/display +POST /api/remote/sessions/{id}/clipboard +POST /api/remote/sessions/{id}/disconnect +GET /api/remote/status +``` + +- [ ] **Step 2: Verify route tests fail** + +Run: `rtk err python3 -m unittest gateway.test_remote_desktop_gateway.RemoteDesktopRouteTests -v` + +Expected: FAIL with 404 responses. + +- [ ] **Step 3: Implement strict schemas and relay queues** + +Keep route parsing in `codex_phone_gateway.py` small by delegating to `RemoteDesktopGateway`. Reject unknown body keys, cap request bodies, normalize base64 with validated decoding, and map native errors to stable public codes without exposing paths or exceptions: + +```python +ERROR_STATUS = { + "host_unavailable": 503, + "pairing_expired": 410, + "untrusted_device": 403, + "invalid_signature": 403, + "controller_busy": 409, + "session_expired": 410, +} +``` + +Signaling queues are per-session, monotonically sequenced, bounded to 256 entries, and removed at disconnect or expiry. + +- [ ] **Step 4: Add Cloudflare TURN credential provider** + +Read `CODEPILOT_TURN_KEY_ID` and `CODEPILOT_TURN_API_TOKEN` from the gateway environment. Generate credentials server-side with a five-minute TTL and return only the resulting ICE server array. Never log request headers, the API token, or generated passwords. When unset, return STUN-only configuration and a `relayAvailable: false` capability. + +- [ ] **Step 5: Run gateway suites** + +Run: `rtk err python3 -m unittest gateway.test_remote_desktop_gateway -v` + +Run: `rtk err python3 -m unittest gateway.test_codex_phone_gateway -v` + +Expected: PASS with no live home-directory state dependency in the new tests. + +- [ ] **Step 6: Commit** + +```bash +git add gateway/remote_desktop_gateway.py gateway/codex_phone_gateway.py gateway/test_remote_desktop_gateway.py +git commit -m "feat: add remote desktop gateway APIs" +``` + +## Task 5: Build Mac Permissions, Pairing, And Session Management UI + +**Files:** +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift` +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift` +- Modify: `Sources/CodexAccountSwitcher/main.swift` +- Create: `Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift` + +- [ ] **Step 1: Write failing coordinator state tests** + +Test unavailable permissions, pending local approval, approved/rejected pairing, active-session indicator, revoke-active-device, Mac lock, and emergency disconnect using protocol-backed fake capture and input services. + +- [ ] **Step 2: Verify state tests fail** + +Run: `rtk err swift test --filter RemoteDesktopCoordinatorTests` + +Expected: FAIL because the coordinator does not exist. + +- [ ] **Step 3: Implement permission and lifecycle state** + +Use `CGPreflightScreenCaptureAccess`, `CGRequestScreenCaptureAccess`, `AXIsProcessTrustedWithOptions`, and `NSWorkspace.sessionDidResignActiveNotification`. The coordinator exposes a single immutable snapshot: + +```swift +struct RemoteDesktopStatus: Equatable { + let screenRecordingGranted: Bool + let accessibilityGranted: Bool + let macUnlocked: Bool + let pendingPairing: PendingPairing? + let trustedDevices: [TrustedRemoteDevice] + let activeSession: ActiveRemoteSession? +} +``` + +- [ ] **Step 4: Add a restrained AppKit management window and menu state** + +Add `Remote Desktop…` to the status menu. The window shows permission rows, pairing QR with expiry, pending approval details and key fingerprint, trusted-device revoke actions, active controller/duration, emergency disconnect, and privacy-safe audit entries. Show a red status-item indicator while control is active. + +- [ ] **Step 5: Verify tests and manual permission states** + +Run: `rtk err swift test` + +Run: `rtk err swift build` + +Expected: PASS. Launch locally and verify account switching still works when both permissions are denied. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/CodexAccountSwitcher Tests/CodexAccountSwitcherTests +git commit -m "feat: add remote desktop trust management UI" +``` + +## Task 6: Implement Mac Capture, WebRTC, And Input + +**Files:** +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/ScreenCaptureService.swift` +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/InputInjector.swift` +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift` +- Modify: `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift` +- Create: `Tests/CodexAccountSwitcherTests/InputInjectorTests.swift` + +- [ ] **Step 1: Write failing coordinate and input-policy tests** + +Cover Retina scaling, negative display origins, rotation, out-of-bounds clamping, stale sequence rejection, lock rejection, and forced modifier release: + +```swift +@Test func mapsNormalizedPointToNegativeOriginDisplay() { + let frame = CGRect(x: -1920, y: 0, width: 1920, height: 1080) + #expect(DisplayCoordinateMapper.map(x: 0.5, y: 0.5, into: frame) == CGPoint(x: -960, y: 540)) +} +``` + +- [ ] **Step 2: Verify input tests fail** + +Run: `rtk err swift test --filter InputInjectorTests` + +Expected: FAIL with missing mapper/injector. + +- [ ] **Step 3: Implement display capture and adaptive frame delivery** + +Enumerate `SCShareableContent.displays`, capture one selected `SCDisplay`, exclude the CodePilot management window, and configure a maximum 1920-pixel long edge at 30 fps initially. Feed `CMSampleBuffer` frames to WebRTC's native video source without JPEG conversion. Support runtime display/filter and resolution updates. + +- [ ] **Step 4: Implement input injection with fail-closed policy** + +Validate the active lease and sequence before creating any `CGEvent`. Clamp normalized coordinates, map through the selected display frame, track pressed keys/buttons, and release all state on disconnect or error. Unicode text uses `keyboardSetUnicodeString`; shortcuts use explicit key-down/up and modifier transitions. + +- [ ] **Step 5: Implement Mac WebRTC peer lifecycle** + +Create one `RTCPeerConnection` per lease with unified-plan semantics, one video track, and one ordered data channel. Accept SDP/ICE only for the active lease. Data messages decode to bounded `RemoteInputEvent` or control envelopes; unknown kinds close the channel with an audit event. Trigger ICE restart on disconnected state and end the lease on failed state after the configured grace period. + +- [ ] **Step 6: Verify local peer and cleanup behavior** + +Run: `rtk err swift test` + +Run: `rtk err swift build` + +Expected: PASS. A local WebRTC loopback harness receives video; disconnect releases all simulated modifiers and stops `SCStream`. + +- [ ] **Step 7: Commit** + +```bash +git add Sources/CodexAccountSwitcher/RemoteDesktop Tests/CodexAccountSwitcherTests +git commit -m "feat: stream and control the Mac desktop" +``` + +## Task 7: Add iOS Secure Identity And Pairing + +**Files:** +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDeviceIdentity.swift` +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift` +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift` +- Modify: `ios/CodexPhone/CodexPhone/CodexPhoneApp.swift` +- Modify: `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift` + +- [ ] **Step 1: Write failing identity and API tests** + +Inject Keychain and authentication protocols. Test stable key identity, non-exportable private key behavior, signature verification fixture, Face ID cancellation, revoked-device response, and pairing expiry. + +- [ ] **Step 2: Verify iOS tests fail** + +Run: `rtk err xcodebuild test -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -destination 'platform=iOS Simulator,name=iPhone 17' CODE_SIGNING_ALLOWED=NO` + +Expected: FAIL with missing identity/API types. + +- [ ] **Step 3: Implement Secure Enclave identity** + +Generate a P-256 key with `kSecAttrTokenIDSecureEnclave`, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, and user-presence access control. Store only the key reference. Expose public X9.63 representation and DER ECDSA signatures. Simulator tests use an injected software-key implementation. + +- [ ] **Step 4: Implement pairing flow** + +Use VisionKit/DataScanner where available and a manual code fallback. Show the Mac identity, challenge expiry, and approval-waiting state. Do not place the pairing payload, public key, or signatures in `UserDefaults` or logs. + +- [ ] **Step 5: Add remote desktop entry point** + +Add a desktop icon button to the main toolbar and a settings section showing paired/unpaired, Mac permission status, active controller, and trusted device name. Disable Start Session with a precise reason when host permissions are missing or another controller is active. + +- [ ] **Step 6: Run iOS tests and build** + +Run: `rtk err xcodebuild test -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -destination 'platform=iOS Simulator,name=iPhone 17' CODE_SIGNING_ALLOWED=NO` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add ios/CodexPhone +git commit -m "feat: pair trusted CodePilot devices" +``` + +## Task 8: Implement iOS WebRTC Session And Full-Screen Controls + +**Files:** +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift` +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopSession.swift` +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift` +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteControlBar.swift` +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteInputMapper.swift` +- Modify: `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift` + +- [ ] **Step 1: Write failing input and session-state tests** + +Test direct tap, drag, two-finger scroll, relative trackpad movement, monotonic sequences, reconnect suspension, background grace expiry, stale event discard, and disconnect cleanup. + +- [ ] **Step 2: Verify tests fail** + +Run: `rtk err xcodebuild test -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -destination 'platform=iOS Simulator,name=iPhone 17' CODE_SIGNING_ALLOWED=NO` + +Expected: FAIL with missing mapper/session types. + +- [ ] **Step 3: Implement authenticated session negotiation** + +Request a nonce, run `LAContext.evaluatePolicy`, sign `nonce || canonicalCapabilities`, create the lease, then negotiate WebRTC using returned ICE servers. Send signaling through `RemoteDesktopAPI`; poll with `after` sequence until connected, then reduce polling to ICE restarts only. + +- [ ] **Step 4: Implement renderer and gesture modes** + +Wrap `RTCMTLVideoView` in `UIViewRepresentable` with fixed aspect-fit geometry. Direct mode maps gestures to normalized coordinates. Trackpad mode maps deltas relative to the rendered frame. Pinch changes only local zoom/pan. Keep control hit targets outside the remote content coordinate surface. + +- [ ] **Step 5: Implement keyboard, shortcut, quality, and disconnect UI** + +Use a hidden first-responder text input for normal typing and explicit buttons for modifier/function keys. The bottom bar uses icons for keyboard, keys, clipboard, files, mode, and disconnect. Show connecting, direct, relayed, reconnecting, suspended, and failed states with latency. Disconnect remains reachable in every state. + +- [ ] **Step 6: Implement lifecycle policy** + +On background, disable the video track and preserve the lease for 30 seconds. On foreground within the grace period, request current signaling state and resume. After expiry, discard the peer and require Face ID plus a new lease. Never replay queued input after reconnect. + +- [ ] **Step 7: Run iOS tests and simulator UI checks** + +Run: `rtk err xcodebuild test -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -destination 'platform=iOS Simulator,name=iPhone 17' CODE_SIGNING_ALLOWED=NO` + +Run: `rtk err xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build` + +Expected: PASS with no layout overflow at portrait and landscape sizes. + +- [ ] **Step 8: Commit** + +```bash +git add ios/CodexPhone +git commit -m "feat: add iPhone remote desktop controls" +``` + +## Task 9: Add Displays, Clipboard, Files, And Network Hardening + +**Files:** +- Modify: `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift` +- Modify: `Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift` +- Modify: `gateway/remote_desktop_gateway.py` +- Modify: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopSession.swift` +- Modify: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift` +- Modify: `gateway/test_remote_desktop_gateway.py` +- Modify: `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift` + +- [ ] **Step 1: Add failing cross-feature tests** + +Cover display removal during a session, display switch geometry, clipboard size/type rejection, explicit clipboard direction, transfer metadata redaction, direct-to-TURN fallback, ICE restart, host restart, gateway restart, and controller lease loss. + +- [ ] **Step 2: Run focused suites and verify failures** + +Run: `rtk err swift test --filter RemoteDesktop` + +Run: `rtk err python3 -m unittest gateway.test_remote_desktop_gateway -v` + +Run the iOS XCTest command from Task 8. + +Expected: new tests fail on missing behavior. + +- [ ] **Step 3: Implement multi-display switching** + +Return all active displays and update the ScreenCaptureKit content filter without recreating the lease. If the selected display disappears, pause input, select the main display, send updated geometry, and require a fresh frame before re-enabling input. + +- [ ] **Step 4: Implement explicit clipboard and existing file flow integration** + +Limit clipboard to UTF-8 text of at most 1 MiB. Each send or pull requires an explicit UI action and audit metadata only. Reuse `downloadRemoteFile` and existing attachment upload APIs for files; do not send file bytes over the WebRTC data channel. + +- [ ] **Step 5: Implement transport resilience** + +Prefer host/srflx candidates, allow relay candidates from server-generated credentials, expose selected path as direct or relayed, perform ICE restart on network changes, and bound retries to avoid immortal sessions. Video adaptation targets input responsiveness first: 30 to 15 to 8 fps and 1920 to 1280 to 960 long edge. + +Observe local HID activity separately from injected remote events. When local mouse or keyboard activity occurs, pause remote input for three seconds, show that state on iOS, and resume without replaying events received during the pause. + +- [ ] **Step 6: Verify all automated suites** + +Run: `rtk err swift test` + +Run: `rtk err python3 -m unittest discover -s gateway -p 'test_*.py' -v` + +Run the iOS XCTest command from Task 8. + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add Sources/CodexAccountSwitcher gateway ios/CodexPhone +git commit -m "feat: harden remote desktop sessions" +``` + +## Task 10: Security Review, Documentation, And Physical-Device Release Verification + +**Files:** +- Modify: `docs/SECURITY.md` +- Modify: `docs/CLOUDFLARE_SETUP.md` +- Modify: `README.md` +- Modify: `scripts/install-phone-gateway-agent.sh` +- Modify: `scripts/install-phone-cloudflared-agent.sh` +- Test: all Mac, gateway, and iOS suites + +- [ ] **Step 1: Add configuration validation tests** + +Test startup with missing TURN settings, invalid socket parent permissions, stale socket, inaccessible audit directory, and unsupported macOS. Remote desktop must disable itself without preventing gateway chat or account switching. + +- [ ] **Step 2: Run a scoped security scan and fix validated findings** + +Review only the new remote-desktop paths for authentication bypass, replay, IDOR, unsafe deserialization, unbounded allocation, secret logging, socket permission races, path traversal, and session cleanup. Add a regression test before each validated fix. + +- [ ] **Step 3: Document setup and revocation** + +Document the tested macOS deployment target, Screen Recording and Accessibility grants, Cloudflare TURN key/token setup, pairing, Face ID requirement, active-session visibility, device revocation, emergency disconnect, audit location, and the unlocked-session limitation. Explicitly state that the gateway bearer token alone cannot authorize remote desktop. + +- [ ] **Step 4: Run complete automated verification** + +Run: `rtk err swift test` + +Run: `rtk err python3 -m unittest discover -s gateway -p 'test_*.py' -v` + +Run: `rtk err xcodebuild test -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -destination 'platform=iOS Simulator,name=iPhone 17' CODE_SIGNING_ALLOWED=NO` + +Run: `rtk err xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build` + +Expected: every command passes. + +- [ ] **Step 5: Run physical-device matrix** + +Verify pairing approval, Face ID session start, Wi-Fi direct path, cellular TURN path, Wi-Fi-to-cellular handoff, two-display switching, pointer/keyboard/shortcuts, clipboard send/pull, file upload/download, Mac lock, permission revocation, local emergency disconnect, device revocation, gateway restart, and app background/foreground. Record failures by stage-specific error code. + +- [ ] **Step 6: Run and verify the mandatory OTA release** + +Run: + +```bash +cd /Users/homeserver/Developer/de-kleine-eekhoorn/ios +python3 scripts/dke_ota_build.py build --app codexphone --branch release +``` + +Expected: status reaches `complete`; output reports bundle ID `com.tony.codexphone`; both public manifest and IPA return HTTP 200; the OTA install updates the configured CodePilot app rather than creating another instance. + +- [ ] **Step 7: Commit** + +```bash +git add README.md docs/SECURITY.md docs/CLOUDFLARE_SETUP.md scripts Sources gateway ios/CodexPhone +git commit -m "docs: complete secure remote desktop rollout" +``` diff --git a/docs/superpowers/plans/2026-06-28-webrtc-remote-desktop.md b/docs/superpowers/plans/2026-06-28-webrtc-remote-desktop.md new file mode 100644 index 00000000..b6f6f5ef --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-webrtc-remote-desktop.md @@ -0,0 +1,157 @@ +# WebRTC Remote Desktop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace JPEG polling with a ScreenCaptureKit/WebRTC video stream and make the zoomed viewport follow a relative remote cursor until display edges are reached. + +**Architecture:** The Mac captures `CMSampleBuffer` frames with ScreenCaptureKit and feeds them to a native WebRTC video source. The authenticated gateway relays SDP/ICE signaling, while the iOS peer renders the incoming track and uses the existing HTTP input path as a fallback. Cursor coordinates drive a deterministic iOS viewport controller. + +**Tech Stack:** Swift 5.9, ScreenCaptureKit, LiveKit WebRTC XCFramework on macOS, stasel WebRTC XCFramework on iOS, SwiftUI, Python gateway, XCTest, unittest. + +--- + +### Task 1: Cursor-Follow Viewport Model + +**Files:** +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteViewport.swift` +- Modify: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift` +- Test: `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift` + +- [ ] **Step 1: Write failing viewport tests** + +Add tests proving a 2x viewport centers normalized cursor `(0.5, 0.5)`, follows `(0.75, 0.5)`, and clamps at cursor `(1, 1)` without exposing space outside the frame. + +- [ ] **Step 2: Run the focused tests and verify failure** + +Run: `xcodebuild -project ios/CodexPhone/CodexPhone.xcodeproj -scheme CodexPhone -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO test -only-testing:CodexPhoneTests/RemoteDesktopTests` + +Expected: failure because `RemoteViewport` does not exist. + +- [ ] **Step 3: Implement the viewport model** + +Create a value type with `zoom`, `cursor`, and `offset(container:image:)`. Compute the desired offset by placing the normalized cursor at the container center, then clamp the offset to half the scaled overflow on each axis. + +- [ ] **Step 4: Connect cursor updates to the view** + +Replace free-form zoomed pan state with `RemoteViewport`. Keep pinch zoom, but make cursor updates authoritative for pan offset. Render the local cursor above the video at its transformed position. + +- [ ] **Step 5: Run tests and commit** + +Run the focused iOS tests and simulator build. Commit only viewport files with `feat: follow remote cursor while zoomed`. + +### Task 2: ScreenCaptureKit Frame Producer + +**Files:** +- Modify: `Sources/CodexAccountSwitcher/RemoteDesktop/ScreenCaptureService.swift` +- Create: `Sources/CodexAccountSwitcher/RemoteDesktop/WebRTCFrameAdapter.swift` +- Test: `Tests/CodexAccountSwitcherTests/ScreenCaptureServiceTests.swift` + +- [ ] **Step 1: Write failing lifecycle tests** + +Use a fake stream driver to verify `start(displayID:frameHandler:)` starts once, delivers frames, and `stop()` releases the handler and stream. + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `swift test --filter ScreenCaptureServiceTests` + +Expected: failure because streaming APIs are absent. + +- [ ] **Step 3: Implement ScreenCaptureKit streaming** + +Configure `SCStreamConfiguration` for the selected display, 30 fps minimum frame interval, BGRA pixel format, cursor excluded, queue depth 5, and scaled width capped at 1920. Deliver `.screen` sample buffers on a dedicated serial queue. + +- [ ] **Step 4: Implement the WebRTC frame adapter** + +Convert each sample buffer image buffer into `RTCCVPixelBuffer`, timestamp it in nanoseconds, and call the WebRTC video source capturer delegate. + +- [ ] **Step 5: Run tests and commit** + +Run `swift test --filter ScreenCaptureServiceTests` and `swift build`. Commit capture files with `feat: stream display frames with ScreenCaptureKit`. + +### Task 3: Native WebRTC Peer And Signaling + +**Files:** +- Modify: `Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift` +- Modify: `Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopSocketServer.swift` +- Modify: `Sources/CodexAccountSwitcher/main.swift` +- Modify: `gateway/remote_desktop_gateway.py` +- Test: `Tests/CodexAccountSwitcherTests/MacPeerConnectionTests.swift` +- Test: `gateway/test_remote_desktop_gateway.py` + +- [ ] **Step 1: Write failing peer and gateway tests** + +Test offer acceptance, answer generation, local ICE draining, duplicate sequence rejection, disconnect cleanup, and gateway forwarding of a client signal to `session.signal` while returning native outbound signals. + +- [ ] **Step 2: Verify the focused tests fail** + +Run: `swift test --filter MacPeerConnectionTests` and `python3 -m unittest gateway.test_remote_desktop_gateway.RemoteDesktopGatewayTests -v`. + +- [ ] **Step 3: Implement the Mac peer** + +Create one `RTCPeerConnectionFactory`, peer connection, H.264-preferred video transceiver, video source, and track per session. Apply remote SDP, create/set local answer, collect ICE candidates, and expose outbound canonical `MacPeerSignal` values. + +- [ ] **Step 4: Wire host RPC and gateway relay** + +Add `session.start`, `session.signal`, and `session.end` handlers. `session.signal` decodes one inbound signal, applies it to the Mac peer, and returns an array of answer/ICE signals. Gateway POST validates and forwards the signal; GET returns queued host signals only. + +- [ ] **Step 5: Verify and commit** + +Run focused Swift/Python tests, `swift test`, and `swift build`. Commit peer, RPC, and gateway files with `feat: negotiate Mac WebRTC remote desktop peer`. + +### Task 4: iOS WebRTC Client And Renderer + +**Files:** +- Modify: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift` +- Modify: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift` +- Create: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteVideoView.swift` +- Modify: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift` +- Test: `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift` + +- [ ] **Step 1: Write failing API and state tests** + +Test signaling request paths, monotonic sequence values, connected state after receiving a video track, fallback activation before the first frame, and teardown after disconnect. + +- [ ] **Step 2: Verify tests fail** + +Run the focused CodexPhone tests with `xcodebuild`. + +- [ ] **Step 3: Implement iOS negotiation** + +Create the peer factory and receive-only video transceiver, use ICE servers from `/api/remote/status`, create/set the local offer, post signals, apply the answer and ICE candidates, and publish connection/first-frame state on the main actor. + +- [ ] **Step 4: Implement native video rendering and fallback** + +Wrap `RTCMTLVideoView` in `UIViewRepresentable`. Show it full-screen after the first WebRTC frame; run JPEG polling only during negotiation/reconnect and stop polling immediately when video is active. + +- [ ] **Step 5: Verify and commit** + +Run focused tests and the simulator build. Commit iOS peer/API/renderer files with `feat: render WebRTC remote desktop video on iOS`. + +### Task 5: Reconnect, Input Path, And Release Verification + +**Files:** +- Modify: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift` +- Modify: `ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopSession.swift` +- Modify: `Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift` +- Test: `ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift` +- Test: `Tests/CodexAccountSwitcherTests/MacPeerConnectionTests.swift` + +- [ ] **Step 1: Add failing reconnect and teardown tests** + +Test foreground reconnect within grace, fresh negotiation after expiry, no capture after disconnect, and HTTP input fallback while the WebRTC data channel is unavailable. + +- [ ] **Step 2: Implement bounded reconnect** + +Retry negotiation after 0.5, 1, 2, and 4 seconds, then remain on JPEG fallback with an explicit Retry control. Cancel all retry, polling, capture, and peer tasks on exit. + +- [ ] **Step 3: Run full verification** + +Run `swift test`, gateway unittest suites, and the iOS simulator build. Rebuild/restart the signed Mac app and gateway. Verify WebRTC reaches connected state and JPEG requests stop. + +- [ ] **Step 4: Run device and OTA verification** + +Trigger `POST http://127.0.0.1:8787/codexphone/api/build`, wait for complete status, and verify the public manifest and IPA return HTTP 200. Confirm pointer following, edge clamping, keyboard input, reconnect, and fallback on the iPhone. + +- [ ] **Step 5: Commit release-ready changes** + +Commit remaining session/reconnect files with `feat: harden WebRTC remote desktop sessions`. diff --git a/docs/superpowers/plans/2026-06-30-cloudflare-setup-wizard.md b/docs/superpowers/plans/2026-06-30-cloudflare-setup-wizard.md new file mode 100644 index 00000000..96f3e190 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-cloudflare-setup-wizard.md @@ -0,0 +1,907 @@ +# Cloudflare Setup Wizard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a guided Mac app Cloudflare setup flow that installs/configures `cloudflared`, supports permanent hostnames and TryCloudflare, verifies the public gateway URL, and explains each step clearly. + +**Architecture:** Put the automation in a focused shell script with idempotent subcommands, then call that script from a new Mac setup wizard. Keep parsing, status, and error-copy logic testable in Swift and shell tests with temporary HOME and stubbed executables. Preserve existing LaunchAgent behavior while migrating paths to CodePilot naming. + +**Tech Stack:** Swift/AppKit/SwiftPM XCTest for the Mac app, zsh/Python stdlib for setup scripts and script tests, Cloudflare `cloudflared`, macOS LaunchAgents. + +--- + +## File Structure + +- Create `scripts/setup-cloudflare-remote-access.sh`: subcommand-driven Cloudflare setup automation. It owns detection, config generation, metadata writes, LaunchAgent installation, TryCloudflare start, and URL verification. +- Create `Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift`: Swift unit tests for setup status labels, metadata redaction, command/error copy, and script output parsing. +- Create `Tests/CodexAccountSwitcherTests/ScriptFixture.swift`: small XCTest helpers for temp directories and executable stubs if not already covered locally. +- Modify `Sources/CodexAccountSwitcher/main.swift`: add Cloudflare setup models and an AppKit wizard window/sheet, wire buttons to the setup script, improve setup status rows. +- Modify `scripts/install-phone-cloudflared-agent.sh`: delegate to the new setup script or keep as a compatibility wrapper. +- Modify `scripts/start-phone-cloudflared.sh`: use the new CodePilot config path with fallback to the old path. +- Create `scripts/test-cloudflare-setup.sh`: local shell test runner with stubbed `cloudflared`, `brew`, `launchctl`, and `curl`. +- Modify `docs/CLOUDFLARE_SETUP.md`, `docs/INSTALL_MAC.md`, `docs/TROUBLESHOOTING.md`: document the in-app path first and manual fallback second. + +--- + +### Task 1: Add Script Test Harness And Failing Script Tests + +**Files:** +- Create: `scripts/test-cloudflare-setup.sh` +- Test target: `scripts/setup-cloudflare-remote-access.sh` + +- [ ] **Step 1: Create failing script tests** + +Create `scripts/test-cloudflare-setup.sh`: + +```zsh +#!/bin/zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$ROOT/scripts/setup-cloudflare-remote-access.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +export HOME="$TMP/home" +export PATH="$TMP/bin:/usr/bin:/bin:/usr/sbin:/sbin" +mkdir -p "$HOME" "$TMP/bin" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_stub() { + local name="$1" + local body="$2" + cat > "$TMP/bin/$name" </tmp/codepilot-status.json 2>/tmp/codepilot-status.err || true +grep -q "No such file" /tmp/codepilot-status.err && fail "status should not crash when config is missing" + +"$SCRIPT" configure-permanent --hostname codepilot.example.com --tunnel-name codepilot +[ -f "$HOME/.cloudflared/codepilot-config.yaml" ] || fail "config file missing" +grep -q "hostname: codepilot.example.com" "$HOME/.cloudflared/codepilot-config.yaml" || fail "hostname missing from config" +grep -q "service: http://127.0.0.1:18790" "$HOME/.cloudflared/codepilot-config.yaml" || fail "gateway service missing from config" +[ -f "$HOME/.codex-account-switcher/cloudflare-setup.json" ] || fail "metadata missing" +! grep -qi "token" "$HOME/.codex-account-switcher/cloudflare-setup.json" || fail "metadata must not contain token" + +"$SCRIPT" install-service +[ -f "$HOME/Library/LaunchAgents/io.codepilot.phone-cloudflared.plist" ] || fail "LaunchAgent plist missing" + +"$SCRIPT" verify --url https://codepilot.example.com >/tmp/codepilot-verify.out +grep -q "verified" /tmp/codepilot-verify.out || fail "verify output should say verified" + +echo "PASS: cloudflare setup script tests" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +zsh scripts/test-cloudflare-setup.sh +``` + +Expected: FAIL because `scripts/setup-cloudflare-remote-access.sh` does not exist. + +- [ ] **Step 3: Commit failing tests** + +```bash +git add scripts/test-cloudflare-setup.sh +git commit -m "test: cover Cloudflare setup script behavior" +``` + +--- + +### Task 2: Implement Cloudflare Setup Script Core + +**Files:** +- Create: `scripts/setup-cloudflare-remote-access.sh` +- Modify: `scripts/start-phone-cloudflared.sh` +- Modify: `scripts/install-phone-cloudflared-agent.sh` +- Test: `scripts/test-cloudflare-setup.sh` + +- [ ] **Step 1: Implement the setup script** + +Create `scripts/setup-cloudflare-remote-access.sh`: + +```zsh +#!/bin/zsh +set -euo pipefail + +APP_DIR="$HOME/.codex-account-switcher" +CLOUDFLARED_DIR="$HOME/.cloudflared" +CONFIG_PATH="$CLOUDFLARED_DIR/codepilot-config.yaml" +LEGACY_CONFIG_PATH="$CLOUDFLARED_DIR/codex-phone-config.yaml" +METADATA_PATH="$APP_DIR/cloudflare-setup.json" +LABEL="${CODEPILOT_CLOUDFLARED_LAUNCHD_LABEL:-io.codepilot.phone-cloudflared}" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +GATEWAY_URL="${CODEPILOT_GATEWAY_URL:-http://127.0.0.1:18790}" + +mkdir -p "$APP_DIR" "$CLOUDFLARED_DIR" + +json_escape() { + /usr/bin/python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1" +} + +write_metadata() { + local mode="$1" + local hostname="$2" + local tunnel_name="$3" + local tunnel_id="$4" + /usr/bin/python3 - "$METADATA_PATH" "$mode" "$hostname" "$tunnel_name" "$tunnel_id" "$CONFIG_PATH" "$LABEL" <<'PY' +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +path = Path(sys.argv[1]) +payload = { + "mode": sys.argv[2], + "hostname": sys.argv[3], + "tunnelName": sys.argv[4], + "tunnelId": sys.argv[5], + "configPath": sys.argv[6], + "launchAgentLabel": sys.argv[7], + "lastVerifiedAt": None, + "updatedAt": datetime.now(timezone.utc).isoformat(), +} +path.parent.mkdir(parents=True, exist_ok=True) +path.write_text(json.dumps(payload, indent=2) + "\n") +PY +} + +cloudflared_bin() { + if command -v cloudflared >/dev/null 2>&1; then command -v cloudflared; return 0; fi + if [ -x /opt/homebrew/bin/cloudflared ]; then echo /opt/homebrew/bin/cloudflared; return 0; fi + if [ -x /usr/local/bin/cloudflared ]; then echo /usr/local/bin/cloudflared; return 0; fi + return 1 +} + +brew_bin() { + if command -v brew >/dev/null 2>&1; then command -v brew; return 0; fi + if [ -x /opt/homebrew/bin/brew ]; then echo /opt/homebrew/bin/brew; return 0; fi + if [ -x /usr/local/bin/brew ]; then echo /usr/local/bin/brew; return 0; fi + return 1 +} + +status() { + local cf="" + local brew="" + cf="$(cloudflared_bin 2>/dev/null || true)" + brew="$(brew_bin 2>/dev/null || true)" + /usr/bin/python3 - "$cf" "$brew" "$CONFIG_PATH" "$LEGACY_CONFIG_PATH" "$METADATA_PATH" "$PLIST" <<'PY' +import json +import sys +from pathlib import Path +cf, brew, config, legacy, metadata, plist = sys.argv[1:] +print(json.dumps({ + "cloudflaredPath": cf or None, + "homebrewPath": brew or None, + "configPath": config if Path(config).exists() else None, + "legacyConfigPath": legacy if Path(legacy).exists() else None, + "metadataPath": metadata if Path(metadata).exists() else None, + "launchAgentPath": plist if Path(plist).exists() else None, +}, indent=2)) +PY +} + +install_cloudflared() { + if cloudflared_bin >/dev/null 2>&1; then + echo "cloudflared already installed at $(cloudflared_bin)" + return 0 + fi + local brew + brew="$(brew_bin)" || { + echo "Homebrew is missing. Install Homebrew or install cloudflared manually from Cloudflare." >&2 + exit 20 + } + "$brew" install cloudflared +} + +login() { + local cf + cf="$(cloudflared_bin)" || { echo "cloudflared is missing." >&2; exit 21; } + "$cf" tunnel login +} + +configure_permanent() { + local hostname="" + local tunnel_name="codepilot" + while [ $# -gt 0 ]; do + case "$1" in + --hostname) hostname="$2"; shift 2;; + --tunnel-name) tunnel_name="$2"; shift 2;; + *) echo "Unknown argument: $1" >&2; exit 2;; + esac + done + [ -n "$hostname" ] || { echo "--hostname is required" >&2; exit 2; } + + local cf tunnel_id + cf="$(cloudflared_bin)" || { echo "cloudflared is missing." >&2; exit 21; } + tunnel_id="$("$cf" tunnel create "$tunnel_name" 2>/tmp/codepilot-cloudflared-create.err | /usr/bin/python3 -c 'import json,sys,re; s=sys.stdin.read(); +try: print(json.loads(s).get("id","")) +except Exception: print((re.search(r"[0-9a-fA-F-]{20,}", s) or [""])[0])' || true)" + "$cf" tunnel route dns "$tunnel_name" "$hostname" + + cat > "$CONFIG_PATH" </dev/null 2>&1 || true + launchctl bootstrap "gui/$(id -u)" "$PLIST" + launchctl enable "gui/$(id -u)/$LABEL" + launchctl kickstart -k "gui/$(id -u)/$LABEL" + echo "Installed $LABEL" +} + +verify_url() { + local url="" + while [ $# -gt 0 ]; do + case "$1" in + --url) url="$2"; shift 2;; + *) echo "Unknown argument: $1" >&2; exit 2;; + esac + done + [ -n "$url" ] || { echo "--url is required" >&2; exit 2; } + curl -fsS "$url/api/health" >/dev/null + echo "verified $url" +} + +start_trycloudflare() { + local cf + cf="$(cloudflared_bin)" || { echo "cloudflared is missing." >&2; exit 21; } + "$cf" tunnel --url "$GATEWAY_URL" +} + +case "${1:-}" in + status) status;; + install-cloudflared) install_cloudflared;; + login) login;; + configure-permanent) shift; configure_permanent "$@";; + install-service) install_service;; + restart-service) install_service;; + verify) shift; verify_url "$@";; + start-trycloudflare) start_trycloudflare;; + *) echo "Usage: $0 status|install-cloudflared|login|configure-permanent|install-service|restart-service|verify|start-trycloudflare" >&2; exit 2;; +esac +``` + +- [ ] **Step 2: Update compatibility scripts** + +Modify `scripts/start-phone-cloudflared.sh`: + +```zsh +#!/bin/zsh +set -euo pipefail + +CONFIG="$HOME/.cloudflared/codepilot-config.yaml" +LEGACY="$HOME/.cloudflared/codex-phone-config.yaml" +if [ ! -f "$CONFIG" ] && [ -f "$LEGACY" ]; then + CONFIG="$LEGACY" +fi + +exec /opt/homebrew/bin/cloudflared tunnel --config "$CONFIG" run +``` + +Modify `scripts/install-phone-cloudflared-agent.sh`: + +```zsh +#!/bin/zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec "$ROOT/scripts/setup-cloudflare-remote-access.sh" install-service +``` + +- [ ] **Step 3: Run script tests** + +Run: + +```bash +zsh scripts/test-cloudflare-setup.sh +``` + +Expected: `PASS: cloudflare setup script tests`. + +- [ ] **Step 4: Commit script implementation** + +```bash +git add scripts/setup-cloudflare-remote-access.sh scripts/start-phone-cloudflared.sh scripts/install-phone-cloudflared-agent.sh scripts/test-cloudflare-setup.sh +git commit -m "feat: add Cloudflare setup automation" +``` + +--- + +### Task 3: Add Swift Cloudflare Setup Models And Tests + +**Files:** +- Create: `Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift` +- Modify: `Sources/CodexAccountSwitcher/main.swift` +- Test: `Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift` + +- [ ] **Step 1: Write Swift tests for status and copy** + +Create `Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift`: + +```swift +import XCTest +@testable import CodexAccountSwitcher + +final class CloudflareSetupTests: XCTestCase { + func testCloudflareStatusLabelsAreSpecific() { + XCTAssertEqual(CodePilotSetupRequirement.cloudflareMissing.statusLabel, "Missing") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareNeedsConfiguration.statusLabel, "Needs setup") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareReady.statusLabel, "Ready") + } + + func testCloudflareMetadataDoesNotExposeSecrets() throws { + let metadata = CodePilotCloudflareMetadata( + mode: "permanent", + hostname: "codepilot.example.com", + tunnelName: "codepilot", + tunnelId: "tun_123", + configPath: "/Users/test/.cloudflared/codepilot-config.yaml", + launchAgentLabel: "io.codepilot.phone-cloudflared", + lastVerifiedAt: nil + ) + let summary = metadata.safeSummary + XCTAssertTrue(summary.contains("codepilot.example.com")) + XCTAssertFalse(summary.localizedCaseInsensitiveContains("token")) + XCTAssertFalse(summary.localizedCaseInsensitiveContains("credential")) + } + + func testCloudflareScriptErrorMapsToRecoveryCopy() { + XCTAssertEqual( + CodePilotCloudflareErrorMapper.message(forExitCode: 20), + "Homebrew is missing. Install Homebrew or use Cloudflare's manual cloudflared installer, then retry." + ) + XCTAssertEqual( + CodePilotCloudflareErrorMapper.message(forExitCode: 21), + "cloudflared is missing. Install it from the Cloudflare setup step before continuing." + ) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +swift test --filter CloudflareSetupTests +``` + +Expected: FAIL because the new enum cases and types are missing. + +- [ ] **Step 3: Add minimal Swift models** + +In `Sources/CodexAccountSwitcher/main.swift`, extend `CodePilotSetupRequirement`: + +```swift +case cloudflareMissing +case cloudflareNeedsConfiguration +``` + +Update `statusLabel`: + +```swift +case .cloudflareNeedsConfiguration: + return "Needs setup" +case .cloudflareMissing: + return "Missing" +``` + +Add near setup models: + +```swift +struct CodePilotCloudflareMetadata: Codable, Equatable { + let mode: String + let hostname: String + let tunnelName: String + let tunnelId: String + let configPath: String + let launchAgentLabel: String + let lastVerifiedAt: String? + + var safeSummary: String { + let host = hostname.isEmpty ? "No hostname configured" : hostname + return "\(mode) tunnel \(tunnelName) for \(host)" + } +} + +enum CodePilotCloudflareErrorMapper { + static func message(forExitCode code: Int32) -> String { + switch code { + case 20: + return "Homebrew is missing. Install Homebrew or use Cloudflare's manual cloudflared installer, then retry." + case 21: + return "cloudflared is missing. Install it from the Cloudflare setup step before continuing." + default: + return "Cloudflare setup did not finish. Open details, review the last command output, and retry the failed step." + } + } +} +``` + +- [ ] **Step 4: Run Swift tests** + +Run: + +```bash +swift test --filter CloudflareSetupTests +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Swift setup models** + +```bash +git add Sources/CodexAccountSwitcher/main.swift Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift +git commit -m "feat: model Cloudflare setup state" +``` + +--- + +### Task 4: Improve Setup Status Detection + +**Files:** +- Modify: `Sources/CodexAccountSwitcher/main.swift` +- Modify: `Tests/CodexAccountSwitcherTests/SetupStatusTests.swift` + +- [ ] **Step 1: Extend setup status tests** + +Modify `Tests/CodexAccountSwitcherTests/SetupStatusTests.swift`: + +```swift +func testCloudflareStatusLabelsAreUserFacing() { + XCTAssertEqual(CodePilotSetupRequirement.cloudflareReady.statusLabel, "Ready") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareMissing.statusLabel, "Missing") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareNeedsConfiguration.statusLabel, "Needs setup") + XCTAssertEqual(CodePilotSetupRequirement.cloudflareOptional.statusLabel, "Optional") +} +``` + +- [ ] **Step 2: Run test to verify current behavior fails** + +Run: + +```bash +swift test --filter SetupStatusTests +``` + +Expected: FAIL until enum/status changes from Task 3 are present or if not yet integrated. + +- [ ] **Step 3: Implement status row logic** + +In `CodePilotSetupStatus.load()`, replace the current Cloudflare row with: + +```swift +let cloudflareRequirement: CodePilotSetupRequirement +let cloudflareDetail: String +if cloudflared == nil { + cloudflareRequirement = .cloudflareMissing + cloudflareDetail = "Install cloudflared to enable remote iPhone access." +} else if cloudflareMetadataExists() || cloudflareConfigExists() { + cloudflareRequirement = .cloudflareReady + cloudflareDetail = cloudflareReadyDetail(defaultPath: cloudflared) +} else { + cloudflareRequirement = .cloudflareNeedsConfiguration + cloudflareDetail = "cloudflared is installed; set up a tunnel for remote access." +} +``` + +Add helper methods inside `CodePilotSetupStatus`: + +```swift +private static func cloudflareMetadataExists() -> Bool { + let path = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex-account-switcher/cloudflare-setup.json") + return FileManager.default.fileExists(atPath: path.path) +} + +private static func cloudflareConfigExists() -> Bool { + let home = FileManager.default.homeDirectoryForCurrentUser + let modern = home.appendingPathComponent(".cloudflared/codepilot-config.yaml") + let legacy = home.appendingPathComponent(".cloudflared/codex-phone-config.yaml") + return FileManager.default.fileExists(atPath: modern.path) || FileManager.default.fileExists(atPath: legacy.path) +} + +private static func cloudflareReadyDetail(defaultPath: String?) -> String { + if let metadata = loadCloudflareMetadata(), !metadata.hostname.isEmpty { + return metadata.hostname + } + return defaultPath ?? "Configured" +} + +private static func loadCloudflareMetadata() -> CodePilotCloudflareMetadata? { + let path = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex-account-switcher/cloudflare-setup.json") + guard let data = try? Data(contentsOf: path) else { return nil } + return try? JSONDecoder().decode(CodePilotCloudflareMetadata.self, from: data) +} +``` + +- [ ] **Step 4: Run setup status tests** + +Run: + +```bash +swift test --filter SetupStatusTests +``` + +Expected: PASS. + +- [ ] **Step 5: Commit status detection** + +```bash +git add Sources/CodexAccountSwitcher/main.swift Tests/CodexAccountSwitcherTests/SetupStatusTests.swift +git commit -m "feat: improve Cloudflare setup status" +``` + +--- + +### Task 5: Add Mac App Cloudflare Wizard UI + +**Files:** +- Modify: `Sources/CodexAccountSwitcher/main.swift` +- Test: `swift build` + +- [ ] **Step 1: Add a wizard entry button** + +In `CodePilotSetupWindowController.buildUI()`, change the Cloudflare section buttons to: + +```swift +root.addArrangedSubview(section( + title: "Cloudflare Remote Access", + buttons: [ + button("Set Up Remote Access...", #selector(openCloudflareWizard)), + button("Restart Tunnel", #selector(restartCloudflareTunnel)), + button("Open Cloudflare Guide", #selector(openCloudflareGuide)) + ] +)) +``` + +- [ ] **Step 2: Add wizard action skeleton** + +Add methods to `CodePilotSetupWindowController`: + +```swift +@objc private func openCloudflareWizard() { + let controller = CodePilotCloudflareWizardController(parent: self) + window?.beginSheet(controller.window!) { [weak self] _ in + self?.refreshStatus() + } +} + +@objc private func restartCloudflareTunnel() { + runBundledScript(named: "setup-cloudflare-remote-access.sh", arguments: ["restart-service"]) +} +``` + +Change `runBundledScript` signature: + +```swift +private func runBundledScript(named name: String, arguments: [String] = [], force: Bool = false) +``` + +Build arguments: + +```swift +process.arguments = [script.path] + arguments + (force ? ["--force"] : []) +``` + +- [ ] **Step 3: Add the wizard controller** + +Add a new `CodePilotCloudflareWizardController: NSWindowController` near `CodePilotSetupWindowController`. It should include: + +```swift +private final class CodePilotCloudflareWizardController: NSWindowController { + private weak var parentSetup: CodePilotSetupWindowController? + private let outputLabel = NSTextField(wrappingLabelWithString: "") + private let hostnameField = NSTextField(string: "") + private let tunnelNameField = NSTextField(string: "codepilot") + + init(parent: CodePilotSetupWindowController) { + self.parentSetup = parent + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 520), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Set Up Cloudflare Remote Access" + super.init(window: window) + buildUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} +``` + +The UI must show: + +- Explanation text covering outbound tunnel, config path, LaunchAgent, and token requirement. +- Buttons: + - `Install cloudflared` + - `Sign in or Create Cloudflare Account` + - `Configure Permanent Hostname` + - `Start Temporary Test URL` + - `Close` +- Hostname field with placeholder `codepilot.example.com`. +- Tunnel name field default `codepilot`. +- Output label for current step. + +- [ ] **Step 4: Wire wizard commands** + +Add helper: + +```swift +private func runCloudflareStep(_ arguments: [String], successMessage: String) { + // Locate setup-cloudflare-remote-access.sh using the same candidate logic as runBundledScript. + // Run /bin/zsh with [script.path] + arguments. + // Capture stdout/stderr. + // On success show successMessage plus last output. + // On failure show CodePilotCloudflareErrorMapper.message(forExitCode: process.terminationStatus). +} +``` + +Button actions: + +```swift +@objc private func installCloudflared() { + runCloudflareStep(["install-cloudflared"], successMessage: "cloudflared is installed.") +} + +@objc private func loginCloudflare() { + runCloudflareStep(["login"], successMessage: "Cloudflare login completed.") +} + +@objc private func configurePermanent() { + let hostname = hostnameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let tunnelName = tunnelNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "codepilot" : tunnelNameField.stringValue + guard !hostname.isEmpty else { + outputLabel.stringValue = "Enter a hostname such as codepilot.example.com." + return + } + runCloudflareStep(["configure-permanent", "--hostname", hostname, "--tunnel-name", tunnelName, "install-service", "verify", "--url", "https://\(hostname)"], successMessage: "Remote access is configured for https://\(hostname).") +} + +@objc private func startTemporary() { + runCloudflareStep(["start-trycloudflare"], successMessage: "Temporary Cloudflare URL started. Use this only for testing.") +} +``` + +If the combined command shape is too awkward, call the script steps sequentially from Swift and stop on first failure. + +- [ ] **Step 5: Build** + +Run: + +```bash +swift build +``` + +Expected: Build succeeds. + +- [ ] **Step 6: Commit wizard UI** + +```bash +git add Sources/CodexAccountSwitcher/main.swift +git commit -m "feat: add Cloudflare setup wizard" +``` + +--- + +### Task 6: Add Documentation Updates + +**Files:** +- Modify: `docs/CLOUDFLARE_SETUP.md` +- Modify: `docs/INSTALL_MAC.md` +- Modify: `docs/TROUBLESHOOTING.md` + +- [ ] **Step 1: Update Cloudflare setup guide** + +Replace `docs/CLOUDFLARE_SETUP.md` with: + +```markdown +# Cloudflare Setup + +Cloudflare Tunnel is the recommended way to reach the CodePilot gateway from outside the Mac's local network. + +## Recommended: In-App Setup + +Open **CodePilot > Setup CodePilot... > Cloudflare Remote Access > Set Up Remote Access...**. + +The wizard explains and can configure: + +- `cloudflared`, Cloudflare's local tunnel daemon. +- A Cloudflare sign-in or account creation step. +- A permanent hostname such as `codepilot.example.com`. +- A temporary TryCloudflare URL for testing without a domain. +- A macOS LaunchAgent that keeps the tunnel running. + +CodePilot does not open inbound ports. Cloudflare Tunnel makes outbound connections to Cloudflare. The iPhone app still needs the CodePilot gateway token. + +## Permanent Hostname + +Choose this for regular use. You need a Cloudflare account and a domain managed by Cloudflare. + +The wizard creates or reuses a tunnel, writes: + +```text +~/.cloudflared/codepilot-config.yaml +``` + +and installs: + +```text +~/Library/LaunchAgents/io.codepilot.phone-cloudflared.plist +``` + +## Temporary TryCloudflare + +Choose this only for testing. Cloudflare creates a temporary `trycloudflare.com` URL. It can change and should not be treated as a permanent iPhone setup URL. + +## Manual Fallback + +Run: + +```sh +scripts/setup-cloudflare-remote-access.sh status +scripts/setup-cloudflare-remote-access.sh install-cloudflared +scripts/setup-cloudflare-remote-access.sh login +scripts/setup-cloudflare-remote-access.sh configure-permanent --hostname codepilot.example.com --tunnel-name codepilot +scripts/setup-cloudflare-remote-access.sh install-service +scripts/setup-cloudflare-remote-access.sh verify --url https://codepilot.example.com +``` + +## Troubleshooting + +- **Homebrew missing**: install Homebrew or install `cloudflared` manually from Cloudflare. +- **Hostname not on Cloudflare**: add the domain to Cloudflare first or use TryCloudflare. +- **502 from Cloudflare**: start the CodePilot gateway and restart the Cloudflare tunnel. +- **401/403**: copy the current gateway token from the Mac app into the iPhone app. +- **Works locally but not remotely**: check the Cloudflare LaunchAgent logs in `~/Library/Logs/`. +``` + +- [ ] **Step 2: Update Mac install guide** + +In `docs/INSTALL_MAC.md`, add: + +```markdown +## 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. +``` + +- [ ] **Step 3: Update troubleshooting guide** + +In `docs/TROUBLESHOOTING.md`, add: + +```markdown +## Cloudflare Setup Fails + +Open **Setup CodePilot... > Cloudflare Remote Access** and expand the details for the failed step. + +- If `cloudflared` is missing, run the install step or install it manually from Cloudflare. +- If Homebrew is missing, install Homebrew or use Cloudflare's manual package. +- If login fails, rerun **Sign in or Create Cloudflare Account**. +- If DNS routing fails, confirm the hostname belongs to a Cloudflare-managed domain. +- If public verification fails, confirm the local gateway is running and restart the tunnel. +``` + +- [ ] **Step 4: Commit docs** + +```bash +git add docs/CLOUDFLARE_SETUP.md docs/INSTALL_MAC.md docs/TROUBLESHOOTING.md +git commit -m "docs: document Cloudflare setup wizard" +``` + +--- + +### Task 7: Full Verification And Cleanup + +**Files:** +- Verify all changed files. + +- [ ] **Step 1: Run script tests** + +Run: + +```bash +zsh scripts/test-cloudflare-setup.sh +``` + +Expected: `PASS: cloudflare setup script tests`. + +- [ ] **Step 2: Run Swift tests** + +Run: + +```bash +swift test +``` + +Expected: All tests pass. + +- [ ] **Step 3: Run Swift build** + +Run: + +```bash +swift build +``` + +Expected: Build succeeds. + +- [ ] **Step 4: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: only unrelated pre-existing files remain modified, or a clean tree if those are separately handled. + +- [ ] **Step 5: Final commit if needed** + +If verification caused small fixes: + +```bash +git add Sources/CodexAccountSwitcher/main.swift Tests/CodexAccountSwitcherTests scripts docs +git commit -m "test: verify Cloudflare setup wizard" +``` + diff --git a/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md b/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md new file mode 100644 index 00000000..506d14f1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md @@ -0,0 +1,294 @@ +# CodePilot Launch Agent System Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development for build tasks and codex recurring automations for ongoing operations. This plan defines always-on launch agents, their scopes, intervention rules, and the work needed before they can run safely. + +**Goal:** Roll out CodePilot successfully, keep the Mac app, iOS app, gateway, docs, and marketing presence maintained, and support subscription revenue without requiring constant manual coordination. + +**Architecture:** Use a small set of persistent scheduled agents with narrow responsibilities. Agents report only when a human decision, account credential, payment, legal choice, or platform approval is required. Product changes still flow through GitHub issues, branches, tests, builds, and releases. + +**Tech Stack:** GitHub Issues, GitHub Pages, App Store Connect/TestFlight, Fastlane/asc, Cloudflare Tunnel, CodePilot Mac app, CodePilot iOS app, Swift/SwiftUI, shell automation, Codex recurring automations. + +--- + +## Operating Principles + +- Agents must not invent credentials, create accounts with fake identity, spam communities, or bypass platform rules. +- Reddit promotion must be transparent: disclose the creator relationship and follow subreddit rules. +- Anything involving App Store submission, payments, pricing, legal terms, public claims, or user data policy requires explicit human approval. +- Agents should ping Tony in the current Codex thread only when intervention is necessary. +- Routine findings should become GitHub issues, PRs, docs updates, or release notes without a ping. +- Every code change must be tested before being marked complete. +- Every iOS app change must trigger the project OTA build process before completion. + +## Escalation Rules + +Ping Tony only for: + +- Apple, Cloudflare, GitHub, Reddit, or payment-provider authentication. +- App Store Connect agreements, pricing, tax, banking, privacy, age rating, export compliance, or review submission approval. +- A production outage affecting installs, auth, account switching, gateway availability, or paid users. +- A security issue involving secrets, auth tokens, remote desktop access, user data, or public endpoints. +- A public post, launch announcement, paid promotion, or subreddit submission that needs final approval. +- A product decision that affects subscription value, pricing, usage limits, or public positioning. + +Do not ping Tony for: + +- Routine dependency updates that pass tests. +- GitHub issue triage. +- Draft docs, draft website copy, draft Reddit posts, draft screenshots, or draft release notes. +- Non-critical bugs that can be filed and prioritized. +- Build failures that can be reproduced and fixed without credentials. + +## Agent 1: Setup Experience Agent + +**Mission:** Make first-run installation and configuration simple, intuitive, and transparent for ordinary users. + +**Continuous cadence:** Daily during pre-launch, weekly after stable public beta. + +**Responsibilities:** + +- Audit Mac app setup flows for Cloudflare, Codex auth, gateway install, LaunchAgent health, permissions, and account setup. +- Audit iOS app onboarding for gateway URL, pairing, auth status, notifications, file upload, remote desktop, and account switching. +- Convert confusing errors into specific recovery actions. +- Maintain setup docs and screenshots. +- Create issues for every setup friction point found. + +**Inputs:** + +- GitHub issues tagged `setup`, `onboarding`, `install`, `cloudflare`, `auth`. +- TestFlight feedback. +- Gateway logs. +- Mac app setup status. +- iOS screenshots uploaded from user reports. + +**Outputs:** + +- Pull requests for setup UX fixes. +- Updated `docs/INSTALL_MAC.md`, `docs/CLOUDFLARE_SETUP.md`, and iOS onboarding text. +- GitHub issues with reproduction steps. +- No direct ping unless credentials, paid accounts, or a platform decision is required. + +## Agent 2: Maintenance Agent + +**Mission:** Keep the iOS app and gateway healthy based on GitHub issues and automated checks. + +**Continuous cadence:** Every 2 hours for issue triage and health checks; daily for dependency/build review. + +**Responsibilities:** + +- Triage new GitHub issues. +- Reproduce bugs where possible. +- Label severity and affected component: `ios`, `mac`, `gateway`, `cloudflare`, `auth`, `remote-desktop`, `release`. +- Fix low-risk bugs directly. +- Run focused tests and full verification before PRs. +- Check gateway health, Cloudflare tunnel status, OTA endpoint status, and TestFlight build availability. + +**Outputs:** + +- GitHub issue comments with diagnosis. +- PRs for fixes. +- Release notes draft when fixes are user-visible. +- Ping only for outages, secrets, credentials, App Store blocks, or ambiguous product decisions. + +## Agent 3: Release Agent + +**Mission:** Keep TestFlight and OTA builds current and release-ready. + +**Continuous cadence:** On merge to main, nightly, and before public announcements. + +**Responsibilities:** + +- Build Mac app and iOS app. +- Run OTA release when iOS files change. +- Upload TestFlight builds when requested or when a release milestone is ready. +- Track App Store Connect processing. +- Maintain screenshots, metadata, privacy labels, and release notes. +- Prevent bundle-id drift that creates a second app install. + +**Outputs:** + +- OTA build status. +- TestFlight build number and processing status. +- App Store metadata diff. +- Ping only for App Store credentials, legal/compliance forms, pricing, or submit-for-review approval. + +## Agent 4: Presence Agent + +**Mission:** Create and maintain CodePilot’s public presence. + +**Continuous cadence:** Daily until launch site is complete, weekly afterward. + +**Responsibilities:** + +- Build GitHub Pages website. +- Maintain README, install guide, privacy policy, terms, changelog, FAQ, and troubleshooting. +- Generate App Store screenshots and marketing visuals. +- Keep public copy aligned with actual product capability. +- Ensure no personal names, tokens, private hosts, private emails, or private paths are present in public content. + +**Outputs:** + +- GitHub Pages updates. +- App Store screenshot sets. +- Public README/docs PRs. +- Ping only for final public positioning, pricing claims, privacy/legal approval, or brand decisions. + +## Agent 5: Community Growth Agent + +**Mission:** Promote CodePilot during TestFlight without spam or deceptive behavior. + +**Continuous cadence:** Weekly research and draft queue; publish only after approval. + +**Responsibilities:** + +- Research relevant communities: Reddit, Hacker News, indie dev forums, Swift/iOS communities, AI coding communities, Mac automation communities. +- Draft posts tailored to each community’s rules. +- Track feedback and convert real product problems into GitHub issues. +- Maintain a transparent creator profile. + +**Important constraint:** The agent can prepare the Reddit account plan, profile text, and posts, but Tony must create or approve any Reddit account and final posts. Creating accounts and posting must comply with Reddit rules and subreddit rules. + +**Outputs:** + +- Draft Reddit profile. +- Draft posts and comments. +- Subreddit rule summaries. +- Feedback summaries. +- Ping only for account creation, final post approval, controversial feedback, or paid promotion decisions. + +## Agent 6: Revenue Agent + +**Mission:** Move CodePilot toward subscription income with realistic conversion work. + +**Continuous cadence:** Weekly. + +**Responsibilities:** + +- Maintain pricing hypotheses. +- Identify features required before charging. +- Track conversion funnel: install, setup success, active gateway, first successful turn, retained weekly use, subscription. +- Propose subscription tiers. +- Watch for support burden that would make passive income unrealistic. + +**Outputs:** + +- Pricing proposal docs. +- Funnel metrics dashboard issue/spec. +- Subscription readiness checklist. +- Ping for pricing, payment provider, legal/tax, refund policy, or App Store business model decisions. + +## Agent 7: Security and Trust Agent + +**Mission:** Keep remote access, auth refresh, gateway, uploads, and public distribution safe. + +**Continuous cadence:** Weekly and before every public release. + +**Responsibilities:** + +- Scan for secrets and private data. +- Review public repo contents before push/release. +- Review Cloudflare setup and tunnel exposure. +- Review remote desktop permissions and auth gates. +- Verify file upload handling and localhost proxy restrictions. + +**Outputs:** + +- Security issues with severity. +- PRs for hardening. +- Release blockers if a high-risk issue is found. +- Immediate ping for secrets, remote access bypass, auth token exposure, or unsafe public endpoints. + +## Initial Launch Sequence + +1. **Stabilize public repo** + - Security Agent scans all files. + - Presence Agent removes private names, hosts, emails, paths, and screenshots. + - Maintenance Agent ensures tests pass. + +2. **Make setup shippable** + - Setup Experience Agent audits Mac first-run setup. + - Setup Experience Agent audits iOS first-run setup. + - Release Agent verifies OTA and TestFlight update paths. + +3. **Create public presence** + - Presence Agent builds GitHub Pages. + - Presence Agent prepares screenshots, README, install docs, FAQ, privacy policy, and support page. + +4. **Prepare community launch** + - Community Growth Agent drafts Reddit account profile and launch posts. + - Tony approves account/posting strategy. + - Agent schedules staged, rule-compliant posts. + +5. **Start beta loop** + - Maintenance Agent triages GitHub issues. + - Release Agent ships TestFlight updates. + - Setup Experience Agent reduces onboarding friction. + - Revenue Agent tracks whether users reach paid-value moments. + +6. **Prepare subscription launch** + - Revenue Agent proposes tiers and pricing. + - Presence Agent updates website and App Store copy. + - Security Agent signs off on public release. + - Tony approves pricing, legal, and App Store submission. + +## Proposed Recurring Automations + +### `codepilot-issue-triage` + +- **Cadence:** Every 2 hours. +- **Task:** Check new GitHub issues, label them, reproduce when possible, and either open a fixing PR or comment with next steps. +- **Ping:** Only for production outages, credentials, or unclear product decisions. + +### `codepilot-health-watch` + +- **Cadence:** Hourly. +- **Task:** Check gateway health, Cloudflare tunnel reachability, OTA status, and latest TestFlight availability. +- **Ping:** Only if public install/update paths are broken or gateway is down. + +### `codepilot-setup-audit` + +- **Cadence:** Daily. +- **Task:** Walk through setup docs and app flows, identify friction, file issues, and fix low-risk copy or setup bugs. +- **Ping:** Only for credential or account actions. + +### `codepilot-release-readiness` + +- **Cadence:** Nightly and on demand before releases. +- **Task:** Run tests/builds, summarize release blockers, verify screenshots/metadata, prepare release notes. +- **Ping:** Only for App Store submission approval or compliance questions. + +### `codepilot-presence-maintenance` + +- **Cadence:** Weekly. +- **Task:** Keep GitHub Pages, README, docs, screenshots, FAQ, and changelog current. +- **Ping:** Only for public positioning or legal/privacy approval. + +### `codepilot-community-drafts` + +- **Cadence:** Weekly. +- **Task:** Research communities, draft transparent promotional posts, summarize feedback, and prepare approved posting queue. +- **Ping:** Always before posting publicly. + +### `codepilot-security-scan` + +- **Cadence:** Weekly and before public release. +- **Task:** Scan for secrets, private data, unsafe remote access, auth problems, and public repo leaks. +- **Ping:** Immediately for high-risk findings. + +## Launch Decisions + +1. Public product name is final: `CodePilot`. +2. Reddit and community promotion should use the `codepilotios` identity. +3. TestFlight promotion should stay beta-focused and avoid future pricing/subscription claims. +4. First target audiences are AI coding users and existing Codex CLI users. +5. Continuous agents may create draft GitHub issues and PRs autonomously. + +## Success Metrics + +- 90%+ of new users can complete Mac setup without support. +- iOS app connects to gateway and sends first prompt within 5 minutes after setup. +- No stale-auth or tunnel setup errors without actionable recovery text. +- TestFlight build is always current within 24 hours of a merged iOS fix. +- Public repo contains no private names, tokens, personal hosts, private screenshots, or local paths. +- Weekly community feedback produces actionable issues. +- Subscription readiness is reached only after setup success, support burden, and retention are measurable. diff --git a/docs/superpowers/specs/2026-06-27-secure-remote-desktop-design.md b/docs/superpowers/specs/2026-06-27-secure-remote-desktop-design.md new file mode 100644 index 00000000..cfc65fe9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-secure-remote-desktop-design.md @@ -0,0 +1,173 @@ +# Secure Remote Desktop Design + +## Objective + +Add secure remote viewing and full control of an unlocked Mac desktop to the CodePilot gateway and iOS app. A trusted iPhone can connect unattended after initial local approval, switch between all attached displays, control pointer and keyboard input, use explicit clipboard transfer, and send or retrieve files. + +The remote desktop must not weaken the existing gateway. Possession of the gateway bearer token alone must never authorize screen viewing or input control. + +## Scope + +The first complete release supports: + +- Full pointer, scrolling, keyboard, modifier, function-key, and shortcut input. +- One active controlling device at a time. +- All attached displays, selected one at a time. +- Direct-touch and relative-trackpad interaction modes. +- Explicit bidirectional text clipboard transfer. +- File transfer through the existing authenticated gateway infrastructure. +- Trusted-device unattended access after initial local Mac approval. +- Face ID authorization for each new remote-control lease. +- Operation only while the macOS user session is logged in and unlocked. + +The first release does not support the login window, locked-screen control, simultaneous controllers, continuous clipboard synchronization, audio streaming, or persistent recording. + +## Architecture + +### Native Mac Remote Host + +A new Swift component inside the CodePilot menu-bar app owns all privileged and performance-sensitive macOS behavior: + +- Enumerate displays and capture the selected display with ScreenCaptureKit. +- Encode video with VideoToolbox for WebRTC delivery. +- Inject pointer, scroll, keyboard, and modifier events through Core Graphics. +- Read and write the clipboard only for explicit session actions. +- Report screen-recording, accessibility, lock-state, and capture health. +- Display an always-visible active-session indicator and immediate disconnect control. + +The native host does not open a network listener. It communicates with the gateway through a permission-restricted Unix domain socket. + +### Gateway Coordinator + +The Python gateway remains the public API and signaling boundary. It: + +- Manages pairing challenges and trusted-device public keys. +- Verifies signed session challenges and creates short-lived controller leases. +- Brokers WebRTC SDP and ICE signaling. +- Generates short-lived Cloudflare TURN credentials server-side. +- Coordinates display selection, clipboard actions, and file transfers. +- Maintains an append-only local security audit log. +- Invalidates sessions on expiry, revocation, restart, host failure, or Mac lock. + +### iOS Remote Client + +The CodePilot iOS app adds a dedicated full-screen remote desktop surface containing: + +- WebRTC video renderer. +- Display switcher and connection-quality indicator. +- Direct-touch and relative-trackpad modes. +- Pinch-to-zoom and pan that do not alter Mac display resolution. +- Keyboard entry and a shortcut panel for Command, Option, Control, Escape, Tab, arrows, function keys, and common combinations. +- Explicit clipboard send and pull actions. +- Existing file picker and transfer workflows. +- An always-reachable emergency disconnect action. + +### Cloudflare Boundary + +The existing Cloudflare Tunnel carries HTTPS gateway APIs and WebRTC signaling. Screen video and input use WebRTC directly when possible and short-lived Cloudflare TURN relay credentials when direct connectivity fails. Long-lived TURN secrets remain server-side and are never included in the iOS app. + +## Pairing And Device Trust + +1. The Mac generates a cryptographically random, single-use pairing challenge that expires after two minutes and displays it as a QR code. +2. The iPhone creates a non-exportable Secure Enclave P-256 signing key. +3. The iPhone submits its public key and a signature over the pairing challenge. +4. The Mac requires explicit local approval and shows the device name and key fingerprint. +5. The Mac stores only the public key, device metadata, approval time, and revocation state. + +A rejected, consumed, or expired challenge cannot be reused. Removing a trusted device immediately invalidates all of its active and pending sessions. + +## Session Authorization + +1. The iPhone requests a fresh server nonce using the existing authenticated gateway connection. +2. The app requires successful Face ID or device-owner authentication. +3. The Secure Enclave key signs the nonce plus the requested session capabilities. +4. The gateway verifies device trust, freshness, signature, revocation state, and controller availability. +5. The gateway creates a short-lived, single-controller lease and begins WebRTC negotiation. + +The existing bearer token remains required but is insufficient by itself. Every control message includes the lease identifier and a monotonically increasing sequence number. The host rejects expired leases, stale or duplicate sequence numbers, non-controller input, and all input while macOS is locked. + +WebRTC protects video with DTLS-SRTP and carries input and control messages over an encrypted data channel. TURN credentials are session-specific and short-lived. + +## Control And Coordinate Model + +Each display response includes stable display identity, logical frame, pixel dimensions, scale factor, rotation, and current selection. Pointer coordinates are normalized against the transmitted video frame and converted by the native host into the selected display's global macOS coordinate space. + +Direct-touch mode maps tap, double-tap, long-press, drag, and two-finger scroll to desktop actions. Trackpad mode treats gestures as relative pointer input. Modifier state is explicit and is always released on disconnect, timeout, background suspension, or transport failure. + +Only one controller lease is allowed. Other trusted devices may query availability but cannot view or take control until the current lease ends. + +## Clipboard And Files + +Clipboard contents never synchronize continuously. The user explicitly chooses: + +- Send iPhone text to Mac clipboard. +- Pull Mac text into the iPhone clipboard. + +Clipboard values are not written to logs. Initial support is limited to text with a defined size limit. + +Files use the existing authenticated upload and download path rather than the real-time data channel. Transfers enforce configured size limits, use temporary files with restrictive permissions, and record metadata but not contents in the audit log. + +## User Interface + +The remote desktop occupies the primary screen area. A compact top status region shows the Mac name, connection state, latency, and selected display. A bottom toolbar provides keyboard, shortcuts, clipboard, files, interaction-mode switching, and a red disconnect action. + +Connection errors identify the failed stage rather than reporting a generic network failure. Permission problems link to clear Mac-side remediation in the menu-bar app. + +The Mac menu-bar app provides: + +- Screen Recording and Accessibility permission status. +- Pair-new-device QR flow with local approval. +- Trusted-device list with revoke controls. +- Active-session identity and duration. +- Immediate disconnect. +- Local audit-log viewer. + +## Lifecycle And Failure Handling + +- Brief network interruptions trigger WebRTC ICE restart and reconnection under the same lease. +- Input pauses during reconnect and stale input is discarded rather than replayed. +- Poor bandwidth lowers video resolution and frame rate before compromising input responsiveness. +- Backgrounding iOS suspends video and preserves the lease for a short grace period. Returning after expiry requires Face ID and a new signed challenge. +- Gateway or host restart invalidates active leases. Control never resumes automatically after a process restart. +- Locking macOS immediately stops capture and input and invalidates the lease. +- The host watchdog releases stuck keys and mouse buttons, closes capture sessions, cancels clipboard actions, and removes temporary transfer files. +- Local keyboard or mouse use may temporarily pause remote input to prevent conflicting control. + +## Security And Privacy + +- iPhone private keys remain in Secure Enclave and Keychain. +- Mac pairing records and Cloudflare TURN secrets live outside the repository with owner-only permissions. +- The gateway remote-desktop endpoints require both normal gateway authentication and device-signature authorization. +- Pairing, lease creation, revocation, display changes, clipboard actions, file-transfer metadata, and disconnect reasons are recorded in an append-only local audit log. +- Screen contents, recordings, keystrokes, clipboard values, file contents, bearer tokens, private keys, and TURN long-lived secrets are never logged. +- The native remote host binds no public TCP or UDP listener. + +## Delivery Stages + +1. Pairing, trusted-device management, permission checks, leases, and audit logging. +2. Local-network single-display video and pointer/keyboard control. +3. Remote WebRTC connectivity with Cloudflare TURN fallback, reconnection, and adaptive quality. +4. Multi-display switching, clipboard, files, shortcuts, lifecycle hardening, and UI polish. + +Each stage must preserve ordinary CodePilot chat and account-management behavior when remote-desktop permissions or configuration are absent. + +## Verification + +Automated coverage includes: + +- Pairing expiry, one-time use, malformed requests, approval, and revocation. +- Signature validation, nonce replay, lease expiry, controller exclusion, and sequence replay. +- Gateway and native-host protocol compatibility over the Unix domain socket. +- Display geometry, scaling, rotation, and coordinate conversion. +- Modifier cleanup and input rejection while locked or disconnected. +- Clipboard limits and temporary-file cleanup. +- Gateway restart, host restart, WebRTC interruption, ICE restart, TURN fallback, and lease expiry. +- iOS background and foreground transitions. + +Manual physical-device verification covers Wi-Fi, cellular, network handoff, multiple displays, poor bandwidth, Mac lock, permission revocation, emergency disconnect, and trusted-device revocation. iOS changes require a successful simulator/device build plus the repository-mandated OTA build with publicly reachable manifest and IPA. + +## Platform Basis + +- Apple ScreenCaptureKit provides high-performance display capture and runtime content-filter updates: +- Core Graphics provides low-level keyboard, pointer, and scroll event creation and posting: +- Cloudflare TURN provides relay connectivity with server-generated expiring credentials: diff --git a/docs/superpowers/specs/2026-06-28-webrtc-remote-desktop-design.md b/docs/superpowers/specs/2026-06-28-webrtc-remote-desktop-design.md new file mode 100644 index 00000000..09203d6e --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-webrtc-remote-desktop-design.md @@ -0,0 +1,70 @@ +# CodePilot WebRTC Remote Desktop Design + +## Goal + +Replace request-per-frame JPEG polling with a persistent, low-latency WebRTC video session and make zoomed navigation follow the remote pointer like established trackpad-mode remote desktop clients. + +## Architecture + +The Mac host captures the main display with ScreenCaptureKit. Frames enter a WebRTC video source and are encoded by WebRTC's hardware-backed H.264 pipeline where available. The existing gateway remains the authenticated signaling relay for offer, answer, and ICE candidates. Cloudflare TURN credentials returned by the existing status endpoint provide relay connectivity when direct ICE cannot connect. + +The iOS app creates the offer, exchanges signaling through the gateway, and renders the received video track with an RTC video renderer. JPEG polling remains available only as a reconnect fallback and stops immediately after the WebRTC video track produces frames. + +## Components + +### Mac Host + +- `ScreenCaptureService` owns one ScreenCaptureKit stream for the active remote session. +- `MacPeerConnection` owns the native WebRTC peer connection, video source, video track, ICE handling, and teardown. +- The remote desktop RPC handler starts, signals, and ends a peer session. +- Only one controlling session can be active. + +### Gateway + +- Existing authenticated session signaling endpoints carry SDP and ICE payloads. +- Signaling queues remain bounded and sequence checked. +- TURN credentials remain short-lived and are never persisted or returned to logs. +- The JPEG frame endpoint remains as a temporary fallback. + +### iOS Client + +- `RemotePeerConnection` owns the native iOS WebRTC peer connection and renderer-facing video track. +- Signaling reconnects after foregrounding and obtains a fresh session when the previous lease expired. +- The full-screen remote desktop view renders WebRTC video edge-to-edge. +- JPEG fallback is shown only while WebRTC negotiates or reconnects. + +## Pointer And Viewport + +Finger drags send relative pointer deltas. Input deltas are coalesced so at most one move is in flight and accumulated movement is not lost. + +When zoom is greater than 1x, the viewport follows the remote pointer and attempts to keep it at the visual center. The viewport offset clamps at the captured display edges, allowing the pointer to move away from center only when the viewport has reached an edge. Pinch zoom preserves the pointer as the zoom anchor. Taps click at the current remote pointer position. + +Cursor position is sent over the WebRTC data channel with video-relative normalized coordinates. The iOS client draws the cursor locally for immediate visual feedback; the Mac-composited cursor remains enabled only for JPEG fallback. + +## Keyboard And Input + +The native iOS keyboard streams text, deletion, return, and common modifier keys live. Pointer and keyboard events use the WebRTC data channel when connected and fall back to the authenticated HTTP input endpoint during reconnects. + +## Failure Handling + +- If WebRTC negotiation fails, show a reconnecting state and continue JPEG fallback. +- Retry signaling with bounded exponential backoff. +- Tear down capture, tracks, and input state when the user exits or the app exceeds the background grace period. +- Reject stale signaling and input sequences. +- Surface missing Screen Recording or Accessibility permissions explicitly. + +## Verification + +- Unit tests for signaling sequence handling, relative pointer coalescing, viewport cursor-follow clamping, and teardown. +- Gateway tests for offer, answer, ICE, and fallback behavior. +- Mac integration check confirms ScreenCaptureKit emits frames and WebRTC reaches connected state. +- iOS build and simulator tests cover renderer/session state. +- Device test verifies remote video latency, pointer tracking, zoom-edge clamping, keyboard input, reconnect, and fallback. +- Every iOS change is followed by an OTA build with public manifest and IPA verification. + +## Non-Goals + +- Audio streaming. +- Multi-monitor selection in the first release. +- File transfer over WebRTC. +- Multiple simultaneous controllers. diff --git a/docs/superpowers/specs/2026-06-29-cloudflare-setup-wizard-design.md b/docs/superpowers/specs/2026-06-29-cloudflare-setup-wizard-design.md new file mode 100644 index 00000000..d8e1ee2a --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-cloudflare-setup-wizard-design.md @@ -0,0 +1,193 @@ +# CodePilot Cloudflare Setup Wizard Design + +## Goal + +Make remote access setup in the Mac app smooth enough for public beta users who may not know Cloudflare Tunnel, launch agents, DNS routing, or gateway tokens. The app should explain what it is about to install or configure, ask for consent before making changes, automate the happy path, and leave the user with a verified iPhone connection URL. + +## Scope + +This design covers the Mac menu bar app and Mac-side helper scripts. It does not change the iOS app connection model, gateway authentication model, or Cloudflare account billing/domain setup. Users can either configure a permanent Cloudflare-managed hostname or create a temporary TryCloudflare URL for testing. + +## Current State + +CodePilot already has: + +- A setup window with a Cloudflare row that only checks whether `cloudflared` exists. +- `scripts/install-phone-cloudflared-agent.sh`, which installs a LaunchAgent around `scripts/start-phone-cloudflared.sh`. +- `scripts/start-phone-cloudflared.sh`, which expects `~/.cloudflared/codex-phone-config.yaml`. +- Documentation in `docs/CLOUDFLARE_SETUP.md`. + +The missing pieces are guided login, CLI installation, tunnel creation, DNS routing, config generation, public URL verification, and clear recovery actions. + +## User Experience + +The setup window gets a richer **Cloudflare Remote Access** section with one primary action: **Set Up Remote Access...**. That opens a guided sheet or window. + +The wizard starts with a plain explanation: + +- CodePilot Gateway runs locally on `127.0.0.1:18790`. +- Cloudflare Tunnel lets the iPhone reach that gateway away from the local network. +- The setup may install `cloudflared`, create a tunnel, create a DNS route, write a config file, and install a LaunchAgent. +- CodePilot does not open inbound ports. The tunnel creates outbound connections to Cloudflare. +- The gateway bearer token is still required by the iOS app. + +Each step shows: + +- What will happen. +- Why it is needed. +- The exact command category or file path, without exposing secrets. +- A visible success/failure state. +- A disclosure for raw output and log paths. + +## Setup Paths + +### Permanent Hostname + +This is the recommended path for regular use. + +Inputs: + +- Public hostname, for example `codepilot.example.com`. +- Optional tunnel name, default `codepilot`. + +Flow: + +1. Ensure the local gateway token exists and gateway health is reachable. +2. Ensure `cloudflared` is installed. +3. Run `cloudflared tunnel login` in Terminal or a visible process so the user can sign in or create a Cloudflare account. +4. Create or reuse a named tunnel. +5. Write a config file at `~/.cloudflared/codepilot-config.yaml`. +6. Route DNS for the chosen hostname to the tunnel. +7. Install or update the LaunchAgent. +8. Verify local health and public health. +9. Show the iOS gateway URL and offer to copy it with the token. + +If a tunnel or DNS route already exists, the wizard should detect that and offer to reuse it rather than creating duplicates. + +### Temporary TryCloudflare + +This path is for testing without a Cloudflare-managed domain. + +Flow: + +1. Ensure the gateway is running. +2. Ensure `cloudflared` is installed. +3. Start `cloudflared tunnel --url http://127.0.0.1:18790`. +4. Parse the generated `trycloudflare.com` URL. +5. Verify public health through the temporary URL. +6. Mark the result as temporary and not suitable for long-term iPhone setup. + +TryCloudflare should not install the persistent LaunchAgent by default because the hostname is temporary. + +## Installation Behavior + +The app should detect prerequisites in this order: + +1. Existing `cloudflared` on `PATH` or common Homebrew paths. +2. Homebrew at `/opt/homebrew/bin/brew` or `/usr/local/bin/brew`. +3. Missing Homebrew. + +If `cloudflared` is missing and Homebrew is present, show an explanation and ask before running `brew install cloudflared`. + +If Homebrew is missing, do not run a blind shell installer. Show why Homebrew is needed, link to Homebrew and Cloudflare installation docs, and let the user retry after installation. + +## Files And Services + +Use CodePilot naming for new artifacts while remaining compatible with the old script path during migration. + +New preferred paths: + +- Config: `~/.cloudflared/codepilot-config.yaml` +- Metadata: `~/.codex-account-switcher/cloudflare-setup.json` +- LaunchAgent label: `io.codepilot.phone-cloudflared` +- Logs: + - `~/Library/Logs/codepilot-cloudflared.out.log` + - `~/Library/Logs/codepilot-cloudflared.err.log` + +The metadata file stores non-secret setup state: + +- mode: `permanent` or `trycloudflare` +- hostname +- tunnel name +- tunnel id if available +- config path +- launch agent label +- last verified time + +It must not store Cloudflare credentials, gateway tokens, account ids, or raw certificate contents. + +## Script Design + +Add a single setup script with subcommands, for example `scripts/setup-cloudflare-remote-access.sh`. + +Suggested subcommands: + +- `status` +- `install-cloudflared` +- `login` +- `configure-permanent --hostname --tunnel-name ` +- `start-trycloudflare` +- `install-service` +- `restart-service` +- `verify --url ` + +The Mac app calls these subcommands and streams compact output into the wizard. Scripts should be idempotent and safe to rerun. + +## Error Handling + +Errors should map to user-facing recovery text: + +- `cloudflared` missing: install with Homebrew or open Cloudflare downloads. +- Homebrew missing: install Homebrew or use Cloudflare manual package. +- Not logged in: sign in or create a Cloudflare account in the browser opened by `cloudflared tunnel login`. +- Hostname not on Cloudflare: add the domain to Cloudflare first or use TryCloudflare. +- DNS route conflict: choose a different hostname or reuse existing route. +- Gateway down: restart gateway before configuring tunnel. +- Public verification failed: show local gateway status, tunnel service status, hostname, and last Cloudflare log lines. + +Do not show generic “failed” without next steps. + +## Security + +- Keep the gateway bearer token required for all iOS calls. +- Do not write secrets to logs or setup metadata. +- Do not expose the gateway directly to the LAN or internet. +- Show that Cloudflare Tunnel is an outbound connection. +- Keep raw output behind a disclosure so ordinary users are not overwhelmed, but diagnostics are available. +- Reuse the existing “restart gateway when idle” safety model; Cloudflare setup should not interrupt active turns. + +## Testing + +Mac-side tests should cover: + +- Setup status labels for Cloudflare states. +- Parsing `cloudflared` status output. +- Config generation for permanent tunnels. +- Metadata redaction and serialization. +- Error mapping from script exit codes to recovery copy. + +Script tests should run with temporary HOME directories and stub `cloudflared`, `brew`, and `launchctl` executables so they do not touch the real machine. + +Manual verification should cover: + +- Existing `cloudflared` path. +- Missing `cloudflared` with Homebrew present. +- Missing Homebrew. +- Permanent hostname setup. +- TryCloudflare temporary URL. +- LaunchAgent restart. +- Public health verification. + +## Documentation + +Update: + +- `docs/CLOUDFLARE_SETUP.md`: explain the new in-app flow first, manual commands second. +- `docs/INSTALL_MAC.md`: mention remote access setup from the Mac app. +- `docs/TROUBLESHOOTING.md`: include Cloudflare setup failures and recovery steps. + +## Implementation Decisions + +The implementation should prefer an in-app wizard over a terminal-only setup script. Terminal is still acceptable for `cloudflared tunnel login` because Cloudflare owns that browser-based sign-in flow. + +The permanent setup path should be the default recommendation. TryCloudflare should be clearly labelled as temporary testing. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 1ce31e51..ec83b207 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -3,7 +3,9 @@ import argparse import base64 +import contextlib from datetime import datetime, timezone +import fcntl import hashlib import json import mimetypes @@ -24,6 +26,11 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +try: + from .remote_desktop_gateway import RemoteDesktopGateway +except ImportError: + from remote_desktop_gateway import RemoteDesktopGateway + HOME = Path.home() DEFAULT_CODEX_HOME = HOME / ".codex" @@ -62,6 +69,8 @@ REMOTE_LOGIN_URL_RE = re.compile(r"https?://\S+") REMOTE_LOGIN_URL_TIMEOUT_SECONDS = 15 REMOTE_LOGIN_CALLBACK_TIMEOUT_SECONDS = 30 +LOCAL_WEB_SESSION_TIMEOUT_SECONDS = 60 * 60 +LOCAL_WEB_MAX_BYTES = 25 * 1024 * 1024 JOBS = {} JOB_PROCESSES = {} @@ -561,7 +570,28 @@ def json_response(handler, status: int, payload: dict): handler.wfile.write(body) -def decode_body(handler) -> dict: +def error_payload(code: str, message: str, recovery: str, details: dict | None = None) -> dict: + payload = { + "error": { + "code": str(code or "gateway_unavailable"), + "message": str(message or "CodePilot Gateway request failed."), + "recovery": str(recovery or "Try again, then restart CodePilot Gateway if the problem continues."), + } + } + if details: + payload["error"]["details"] = details + return payload + + +def json_error(handler, status: int, code: str, message: str, recovery: str, details: dict | None = None): + json_response(handler, status, error_payload(code, message, recovery, details)) + + +class RequestBodyTooLarge(ValueError): + pass + + +def decode_body(handler, max_length: int | None = None) -> dict: raw_length = handler.headers.get("content-length", "0") try: length = int(raw_length) @@ -569,6 +599,8 @@ def decode_body(handler) -> dict: length = 0 if length <= 0: return {} + if max_length is not None and length > max_length: + raise RequestBodyTooLarge("request_too_large") body = handler.rfile.read(length) return json.loads(body.decode("utf-8")) @@ -643,6 +675,19 @@ def file_response(handler: BaseHTTPRequestHandler, path: Path): shutil.copyfileobj(handle, handler.wfile) +def local_web_response(handler: BaseHTTPRequestHandler, payload: dict): + body = payload.get("body") or b"" + if isinstance(body, str): + body = body.encode("utf-8") + status = int(payload.get("status") or 502) + handler.send_response(status) + handler.send_header("Content-Type", str(payload.get("contentType") or "application/octet-stream")) + handler.send_header("Cache-Control", "no-store") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + def truncate_text(text: str, limit: int = 900) -> str: compacted = re.sub(r"\s+", " ", text).strip() if len(compacted) <= limit: @@ -667,6 +712,27 @@ def compact_json(value, limit: int = 900) -> str: return truncate_text(str(value), limit) +def fetch_local_web_url(url: str) -> tuple[int, dict, bytes]: + request = urllib.request.Request( + url, + headers={ + "User-Agent": "CodePilot/0.1 local-web-proxy", + "Accept": "*/*", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(LOCAL_WEB_MAX_BYTES + 1) + if len(body) > LOCAL_WEB_MAX_BYTES: + raise RuntimeError("Local web response is too large") + return int(response.status), dict(response.headers.items()), body + except urllib.error.HTTPError as exc: + body = exc.read(LOCAL_WEB_MAX_BYTES + 1) + if len(body) > LOCAL_WEB_MAX_BYTES: + raise RuntimeError("Local web response is too large") + return int(exc.code), dict(exc.headers.items()), body + + def is_stale_auth_message(message: str) -> bool: lower = str(message or "").lower() return any(marker in lower for marker in STALE_AUTH_MARKERS) @@ -727,6 +793,21 @@ def epoch_seconds(value) -> int | None: return None +def parse_iso_datetime(value: str) -> datetime | None: + stripped = str(value or "").strip() + if not stripped: + return None + if stripped.endswith("Z"): + stripped = stripped[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(stripped) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + def event_status(raw_status: str | None, stream_type: str) -> str: if raw_status in {"in_progress", "running"} or stream_type.endswith(".started"): return "running" @@ -1704,6 +1785,8 @@ def __init__( login_runner=None, login_process_factory=subprocess.Popen, login_callback_relayer=None, + remote_desktop_gateway=None, + local_web_fetcher=None, ): self.codex_home = codex_home self.token = token @@ -1719,6 +1802,10 @@ def __init__( self.login_runner = login_runner self.login_process_factory = login_process_factory self.login_callback_relayer = login_callback_relayer + self.remote_desktop_gateway = remote_desktop_gateway if remote_desktop_gateway is not None else RemoteDesktopGateway() + self.local_web_fetcher = local_web_fetcher if local_web_fetcher is not None else fetch_local_web_url + self._local_web_sessions = {} + self._local_web_lock = threading.Lock() self._remote_login_sessions = {} self._remote_login_lock = threading.Lock() @@ -1790,6 +1877,44 @@ def app_server_auth_status(self) -> dict: "restartDeferred": restart_deferred, } + def public_health(self) -> dict: + remote_desktop_status = {"available": False} + remote_desktop = self.remote_desktop_gateway + if remote_desktop is not None: + public_status = getattr(remote_desktop, "public_status", None) + if callable(public_status): + try: + remote_desktop_status = public_status() + except Exception as exc: + remote_desktop_status = { + "available": False, + "error": truncate_text(str(exc), 160), + } + else: + remote_desktop_status = {"available": True} + + return { + "gateway": { + "running": True, + "version": read_marker(DEFAULT_SWITCHER_HOME / "gateway-version", "dev"), + }, + "accounts": { + "active": self.active_account_name(), + "auth": self.app_server_auth_status(), + }, + "turns": { + "running": self.has_running_jobs(), + }, + "notifications": { + "configured": not isinstance(self.push_notifier, DisabledPushNotifier), + }, + "remoteDesktop": remote_desktop_status, + "localWeb": { + "available": True, + "sessionSeconds": LOCAL_WEB_SESSION_TIMEOUT_SECONDS, + }, + } + def close_app_server_if_auth_changed_and_idle(self): with self._app_server_lock: self._close_app_server_if_auth_changed_and_idle_locked() @@ -1935,6 +2060,8 @@ def handle_app_server_notification(self, message: dict): self.close_app_server_client() else: self.close_app_server_if_auth_changed_and_idle() + with self.auth_file_lock(): + self.sync_active_auth_to_active_profile() def active_account_name(self) -> str: return read_marker(self.active_account_marker, "unknown") @@ -2698,7 +2825,9 @@ def switch_account(self, raw_name: str) -> dict: if self.has_running_jobs(): raise RuntimeError("Cannot switch account while a Codex turn is running") self._close_app_server_client_locked() - self.install_auth_file(profile_auth, self.active_auth_path) + with self.auth_file_lock(): + self.sync_active_auth_to_active_profile() + self.install_auth_file(profile_auth, self.active_auth_path) self.active_account_marker.parent.mkdir(parents=True, exist_ok=True) self.active_account_marker.write_text(account_name + "\n", encoding="utf-8") @@ -2748,6 +2877,58 @@ def install_auth_file(self, source: Path, target: Path): pass raise + @contextlib.contextmanager + def auth_file_lock(self): + DEFAULT_SWITCHER_HOME.mkdir(parents=True, exist_ok=True) + lock_path = DEFAULT_SWITCHER_HOME / "auth-files.lock" + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + def auth_file_snapshot(self, auth_path: Path) -> dict: + data = json.loads(auth_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("auth.json is not an object") + tokens = data.get("tokens") + if not isinstance(tokens, dict): + tokens = {} + return { + "accountId": str(tokens.get("account_id") or ""), + "lastRefresh": parse_iso_datetime(str(data.get("last_refresh") or "")), + } + + def sync_active_auth_to_active_profile(self) -> bool: + account_name = self.active_account_name() + if not account_name or account_name == "unknown": + return False + profile_auth = self.accounts_dir / account_name / "auth.json" + if not self.active_auth_path.is_file() or not profile_auth.is_file(): + return False + try: + if self.active_auth_path.read_bytes() == profile_auth.read_bytes(): + return False + active = self.auth_file_snapshot(self.active_auth_path) + profile = self.auth_file_snapshot(profile_auth) + except (OSError, json.JSONDecodeError, ValueError): + return False + + active_account = str(active.get("accountId") or "") + profile_account = str(profile.get("accountId") or "") + if active_account and profile_account and active_account != profile_account: + return False + + active_refresh = active.get("lastRefresh") + profile_refresh = profile.get("lastRefresh") + if active_refresh is not None and profile_refresh is not None and active_refresh < profile_refresh: + return False + + self.install_auth_file(self.active_auth_path, profile_auth) + return True + def run_access_token_login(self, temp_codex_home: Path, access_token: str): if self.login_runner is not None: self.login_runner(temp_codex_home, access_token) @@ -2777,21 +2958,25 @@ def install_refreshed_account_auth(self, account_name: str, generated_auth: Path raise RuntimeError("Cannot refresh active account auth while a Codex turn is running") profile_auth = self.accounts_dir / account_name / "auth.json" - self.install_auth_file(generated_auth, profile_auth) - if is_active_account: with self._app_server_lock: if self.has_running_jobs(): raise RuntimeError("Cannot refresh active account auth while a Codex turn is running") self._close_app_server_client_locked() - self.install_auth_file(generated_auth, self.active_auth_path) + with self.auth_file_lock(): + self.install_auth_file(generated_auth, profile_auth) + self.install_auth_file(generated_auth, self.active_auth_path) + else: + with self.auth_file_lock(): + self.install_auth_file(generated_auth, profile_auth) self.record_remote_login_refresh(account_name) def install_new_account_auth(self, account_name: str, generated_auth: Path): account_name = self.sanitized_new_account_profile_name(account_name) profile_auth = self.accounts_dir / account_name / "auth.json" - self.install_auth_file(generated_auth, profile_auth) + with self.auth_file_lock(): + self.install_auth_file(generated_auth, profile_auth) self.record_remote_login_add(account_name) def refresh_account_auth_with_access_token(self, raw_name: str, access_token: str) -> dict: @@ -3104,6 +3289,114 @@ def cleanup_remote_account_login_session(self, session_id: str, terminate: bool) except OSError: pass + def start_local_web_session(self, raw_url: str) -> dict: + parsed = self.validated_local_web_url(raw_url) + session_id = secrets.token_urlsafe(24) + now = int(time.time()) + expires_at = now + LOCAL_WEB_SESSION_TIMEOUT_SECONDS + port = parsed.port or (443 if parsed.scheme == "https" else 80) + target_origin = f"{parsed.scheme}://127.0.0.1:{port}" + session = { + "id": session_id, + "scheme": parsed.scheme, + "port": port, + "createdAt": now, + "expiresAt": expires_at, + "targetOrigin": target_origin, + } + with self._local_web_lock: + self._local_web_sessions[session_id] = session + self.cleanup_expired_local_web_sessions_locked(now) + path = self.local_web_gateway_path(session_id, parsed.path or "/", parsed.query) + return { + "sessionId": session_id, + "path": path, + "targetOrigin": target_origin, + "expiresAt": expires_at, + } + + def validated_local_web_url(self, raw_url: str): + value = str(raw_url or "").strip() + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"}: + raise ValueError("Only http and https localhost URLs can be opened") + hostname = (parsed.hostname or "").casefold() + if hostname not in {"localhost", "127.0.0.1", "::1"}: + raise ValueError("Only localhost URLs can be opened through the Mac gateway") + port = parsed.port or (443 if parsed.scheme == "https" else 80) + if port <= 0 or port > 65535: + raise ValueError("Localhost URL port is invalid") + return parsed + + def cleanup_expired_local_web_sessions_locked(self, now: int): + expired = [ + session_id for session_id, session in self._local_web_sessions.items() + if int(session.get("expiresAt") or 0) <= now + ] + for session_id in expired: + self._local_web_sessions.pop(session_id, None) + + def local_web_gateway_path(self, session_id: str, path: str, query: str = "") -> str: + clean_path = "/" + str(path or "/").lstrip("/") + quoted_path = urllib.parse.quote(clean_path, safe="/:@!$&'()*+,;=-._~") + gateway_path = f"/api/local-web/{session_id}{quoted_path}" + if query: + gateway_path += f"?{query}" + return gateway_path + + def proxy_local_web_session(self, session_id: str, path_parts: list[str], query: str) -> dict: + now = int(time.time()) + with self._local_web_lock: + self.cleanup_expired_local_web_sessions_locked(now) + session = self._local_web_sessions.get(str(session_id or "")) + if not session: + raise LookupError("Local web session not found or expired") + + path = "/" + "/".join(urllib.parse.quote(urllib.parse.unquote(part), safe=":@!$&'()*+,;=-._~") for part in path_parts) + if path == "/": + path = "/" + url = urllib.parse.urlunparse(( + str(session["scheme"]), + f"127.0.0.1:{int(session['port'])}", + path, + "", + str(query or ""), + "", + )) + status, headers, body = self.local_web_fetcher(url) + content_type = str(headers.get("Content-Type") or headers.get("content-type") or "application/octet-stream") + if "text/html" in content_type.lower(): + body = self.rewrite_local_web_html(session, body) + return { + "status": int(status), + "contentType": content_type, + "body": body, + } + + def rewrite_local_web_html(self, session: dict, body: bytes) -> bytes: + try: + text = body.decode("utf-8") + except UnicodeDecodeError: + return body + session_id = str(session["id"]) + port = int(session["port"]) + replacements = [ + (f"http://localhost:{port}", f"/api/local-web/{session_id}"), + (f"http://127.0.0.1:{port}", f"/api/local-web/{session_id}"), + (f"https://localhost:{port}", f"/api/local-web/{session_id}"), + (f"https://127.0.0.1:{port}", f"/api/local-web/{session_id}"), + ] + for source, target in replacements: + text = text.replace(source, target) + session_base = f"/api/local-web/{session_id}" + text = re.sub( + r'''(?P\b(?:src|href|action)=["'])(?P/(?!/|api/local-web/)[^"']*)''', + lambda match: f"{match.group('attr')}{session_base}{match.group('path')}", + text, + flags=re.IGNORECASE, + ) + return text.encode("utf-8") + def read_usage(self) -> dict: try: data = json.loads(self.usage_path.read_text(encoding="utf-8")) @@ -3756,26 +4049,50 @@ def authenticate(self) -> bool: header = self.headers.get("authorization", "") if header == f"Bearer {expected}": return True - json_response(self, 401, {"error": "Unauthorized"}) + json_error( + self, + 401, + "unauthorized", + "Unauthorized", + "Update the CodePilot Gateway token in the iPhone app, then try again.", + ) return False def do_GET(self): - if not self.authenticate(): - return - parsed = urllib.parse.urlparse(self.path) path = parsed.path.rstrip("/") or "/" parts = [part for part in path.split("/") if part] + if len(parts) >= 3 and parts[0] == "api" and parts[1] == "local-web": + try: + local_web_response( + self, + self.state().proxy_local_web_session(parts[2], parts[3:], parsed.query), + ) + except LookupError as exc: + json_error( + self, + 404, + "local_web_unavailable", + str(exc), + "Open the localhost link again from CodePilot; local web sessions expire after a short time.", + ) + except Exception as exc: + json_error( + self, + 502, + "local_web_unavailable", + str(exc), + "Make sure the local web server is running on the Mac, then reopen the link.", + ) + return + + if not self.authenticate(): + return + try: - if path == "/health": - json_response(self, 200, { - "ok": True, - "activeAccount": self.state().active_account_name(), - "appServerAuth": self.state().app_server_auth_status(), - "codexHome": str(self.state().codex_home), - "dangerousMode": self.state().allow_dangerous, - }) + if path == "/health" or path == "/api/health": + json_response(self, 200, self.state().public_health()) return if path == "/api/threads": @@ -3789,6 +4106,21 @@ def do_GET(self): json_response(self, 200, self.state().account_status_snapshot()) return + if len(parts) >= 2 and parts[0] == "api" and parts[1] == "remote": + query = urllib.parse.parse_qs(parsed.query) + status, payload = self.state().remote_desktop_gateway.handle("GET", parts[2:], query, None) + if isinstance(payload, dict) and "_raw" in payload: + raw = payload.get("_raw") or b"" + self.send_response(status) + self.send_header("Content-Type", str(payload.get("_content_type") or "application/octet-stream")) + self.send_header("Cache-Control", str(payload.get("_cache_control") or "no-store")) + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + json_response(self, status, payload) + return + if len(parts) == 3 and parts[0] == "api" and parts[1] == "files": query = urllib.parse.parse_qs(parsed.query) requested_path = (query.get("path") or [""])[0] @@ -3822,7 +4154,13 @@ def do_GET(self): if len(parts) == 3 and parts[0] == "api" and parts[1] == "threads": thread = self.state().get_thread(parts[2]) if not thread: - json_response(self, 404, {"error": "Thread not found"}) + json_error( + self, + 404, + "thread_not_found", + "Thread not found", + "Return to the thread list and refresh. The thread may have been moved, deleted, or not synced yet.", + ) return if "messages" not in thread: rollout_messages = parse_messages(Path(thread["rolloutPath"])) @@ -3844,7 +4182,13 @@ def do_GET(self): with JOBS_LOCK: job = JOBS.get(parts[2]) if not job: - json_response(self, 404, {"error": "Job not found"}) + json_error( + self, + 404, + "job_not_found", + "Job not found", + "Reload the thread. The live stream may have ended and saved messages can still be loaded.", + ) return json_response(self, 200, self.state().job_response(job, after_event_seq)) return @@ -3857,11 +4201,11 @@ def do_GET(self): json_response(self, 200, self.state().remote_account_login_status(parts[4])) return - json_response(self, 404, {"error": "Not found"}) + json_error(self, 404, "gateway_unavailable", "Not found", "Update the app or gateway if this request should be supported.") except LookupError as exc: - json_response(self, 404, {"error": str(exc)}) + json_error(self, 404, "gateway_unavailable", str(exc), "Refresh the app and try again.") except Exception as exc: - json_response(self, 500, {"error": str(exc)}) + json_error(self, 500, "gateway_unavailable", str(exc), "Restart CodePilot Gateway if the problem continues.") def do_POST(self): if not self.authenticate(): @@ -3946,6 +4290,22 @@ def do_POST(self): json_response(self, 200, self.state().register_notification_device(body)) return + if len(parts) == 3 and parts[0] == "api" and parts[1] == "local-web" and parts[2] == "sessions": + body = decode_body(self) + try: + session = self.state().start_local_web_session(str(body.get("url", ""))) + except ValueError as exc: + json_error( + self, + 400, + "local_web_invalid_target", + str(exc), + "Open a localhost, 127.0.0.1, or ::1 URL from the iPhone app.", + ) + return + json_response(self, 200, session) + return + if len(parts) == 4 and parts[0] == "api" and parts[1] == "threads" and parts[3] == "turns": body = decode_body(self) attachments = body.get("attachments") @@ -3987,15 +4347,30 @@ def do_POST(self): json_response(self, 200, {"ok": True}) return - json_response(self, 404, {"error": "Not found"}) + if len(parts) >= 2 and parts[0] == "api" and parts[1] == "remote": + body = decode_body(self, max_length=1_048_576) + status, payload = self.state().remote_desktop_gateway.handle("POST", parts[2:], {}, body) + json_response(self, status, payload) + return + + json_error(self, 404, "gateway_unavailable", "Not found", "Update the app or gateway if this request should be supported.") except LookupError as exc: - json_response(self, 404, {"error": str(exc)}) + json_error(self, 404, "gateway_unavailable", str(exc), "Refresh the app and try again.") + except RequestBodyTooLarge as exc: + json_error(self, 413, "gateway_unavailable", str(exc), "Send a smaller request or fewer attachments.") except ValueError as exc: - json_response(self, 400, {"error": str(exc)}) + json_error(self, 400, "gateway_unavailable", str(exc), "Check the request and try again.") except RuntimeError as exc: - json_response(self, 409, {"error": str(exc)}) + message = str(exc) + code = "auth_stale" if is_stale_auth_message(message) else "active_turn_running" + recovery = ( + "Refresh login for the active account, then try again." + if code == "auth_stale" + else "Wait for the active turn to finish, then try again." + ) + json_error(self, 409, code, message, recovery) except Exception as exc: - json_response(self, 500, {"error": str(exc)}) + json_error(self, 500, "gateway_unavailable", str(exc), "Restart CodePilot Gateway if the problem continues.") def log_message(self, format, *args): print(f"{self.address_string()} - {format % args}", flush=True) diff --git a/gateway/remote_desktop_gateway.py b/gateway/remote_desktop_gateway.py new file mode 100644 index 00000000..df158f9d --- /dev/null +++ b/gateway/remote_desktop_gateway.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import base64 +import binascii +import json +import os +import socket +import threading +import urllib.error +import urllib.request +import uuid +from pathlib import Path +from typing import Any + + +DEFAULT_REMOTE_DESKTOP_SOCKET_PATH = Path.home() / ".codepilot" / "remote-desktop" / "host.sock" +DEFAULT_TIMEOUT_SECONDS = 15.0 +DEFAULT_MAX_REQUEST_BYTES = 1_048_576 +DEFAULT_MAX_RESPONSE_BYTES = 1_048_576 +ALLOWED_RESPONSE_KEYS = {"id", "status", "payload", "errorCode"} +ERROR_STATUS = { + "host_unavailable": 503, + "timeout": 503, + "pairing_expired": 410, + "untrusted_device": 403, + "invalid_signature": 403, + "accessibility_required": 403, + "screen_recording_required": 403, + "controller_busy": 409, + "session_expired": 410, + "sequence_replay": 409, + "request_too_large": 413, + "malformed_response": 502, + "turn_unavailable": 503, +} +IDENTIFIER_MAX_LENGTH = 128 +SIGNAL_QUEUE_LIMIT = 256 +STUN_ONLY_ICE_SERVERS = [{"urls": ["stun:stun.l.google.com:19302"]}] +MAX_CLIPBOARD_TEXT_BYTES = 1_048_576 +REMOTE_INPUT_KEYS = { + "sessionId", "sequence", "kind", "x", "y", "button", "keyCode", + "text", "deltaX", "deltaY", +} +REMOTE_INPUT_KINDS = {"pointer", "buttonDown", "buttonUp", "scroll", "keyDown", "keyUp", "text"} + + +class RemoteDesktopHostError(RuntimeError): + def __init__(self, code: str, message: str | None = None): + self.code = code + super().__init__(message or _default_message_for_code(code)) + + +class NativeHostClient: + def __init__( + self, + socket_path: str | os.PathLike[str] = DEFAULT_REMOTE_DESKTOP_SOCKET_PATH, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + max_request_bytes: int = DEFAULT_MAX_REQUEST_BYTES, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, + ): + self._socket_path = Path(socket_path) + self._timeout_seconds = timeout_seconds + self._max_request_bytes = max_request_bytes + self._max_response_bytes = max_response_bytes + + def call(self, method: str, payload: Any | None = None) -> dict[str, Any]: + method = str(method or "").strip() + if not method: + raise RemoteDesktopHostError("invalid_request") + + request_id = str(uuid.uuid4()) + request = { + "id": request_id, + "method": method, + "payload": _encode_payload(payload), + } + request_bytes = json.dumps(request, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + b"\n" + if len(request_bytes) > self._max_request_bytes: + raise RemoteDesktopHostError("request_too_large") + + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn: + conn.settimeout(self._timeout_seconds) + conn.connect(str(self._socket_path)) + conn.sendall(request_bytes) + response = self._read_response(conn) + except socket.timeout as exc: + raise RemoteDesktopHostError("timeout") from exc + except OSError as exc: + raise RemoteDesktopHostError("host_unavailable") from exc + + parsed = _decode_response(response, request_id) + return parsed + + def _read_response(self, conn: socket.socket) -> bytes: + buffer = bytearray() + while True: + try: + chunk = conn.recv(min(4096, max(1, self._max_response_bytes - len(buffer)))) + except socket.timeout: + raise + if not chunk: + break + buffer.extend(chunk) + if len(buffer) > self._max_response_bytes: + raise RemoteDesktopHostError("response_too_large") + if b"\n" in chunk: + break + + if len(buffer) > self._max_response_bytes: + raise RemoteDesktopHostError("response_too_large") + + line, _, _ = bytes(buffer).partition(b"\n") + if not line: + raise RemoteDesktopHostError("malformed_response") + return line + + +def _encode_payload(payload: Any | None) -> str | None: + if payload is None: + return "" + if isinstance(payload, bytes): + raw = payload + elif isinstance(payload, str): + raw = payload.encode("utf-8") + else: + raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return base64.b64encode(raw).decode("ascii") + + +def _decode_response(raw: bytes, expected_id: str) -> dict[str, Any]: + try: + message = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RemoteDesktopHostError("malformed_response") from exc + + if not isinstance(message, dict): + raise RemoteDesktopHostError("malformed_response") + + if not set(message).issubset(ALLOWED_RESPONSE_KEYS): + raise RemoteDesktopHostError("malformed_response") + + request_id = message.get("id") + status = message.get("status") + payload = message.get("payload") + error_code = message.get("errorCode") + + if not isinstance(request_id, str) or request_id.lower() != expected_id.lower(): + raise RemoteDesktopHostError("malformed_response") + if not isinstance(status, int): + raise RemoteDesktopHostError("malformed_response") + if not isinstance(payload, str): + raise RemoteDesktopHostError("malformed_response") + if error_code is not None and not isinstance(error_code, str): + raise RemoteDesktopHostError("malformed_response") + + try: + decoded_payload = base64.b64decode(payload.encode("ascii"), validate=True) + except (UnicodeError, binascii.Error) as exc: + raise RemoteDesktopHostError("malformed_response") from exc + + return { + "id": request_id, + "status": status, + "payload": decoded_payload, + "errorCode": error_code, + } + + +def _default_message_for_code(code: str) -> str: + if code == "timeout": + return "Remote desktop host timed out" + if code == "response_too_large": + return "Remote desktop host response was too large" + if code == "request_too_large": + return "Remote desktop request was too large" + if code == "malformed_response": + return "Remote desktop host returned an invalid response" + if code == "invalid_request": + return "Remote desktop request is invalid" + return "Remote desktop host is unavailable" + + +class RemoteDesktopGateway: + def __init__( + self, + host_client: NativeHostClient | None = None, + signal_queue_limit: int = SIGNAL_QUEUE_LIMIT, + turn_urlopen=urllib.request.urlopen, + ): + self.host_client = host_client if host_client is not None else NativeHostClient() + self.signal_queue_limit = max(1, int(signal_queue_limit)) + self.turn_urlopen = turn_urlopen + self._signal_lock = threading.Lock() + self._signals: dict[str, list[dict[str, Any]]] = {} + self._last_signal_sequence: dict[str, int] = {} + + def handle( + self, + http_method: str, + parts: list[str], + query: dict[str, list[str]], + body: dict[str, Any] | None, + ) -> tuple[int, dict[str, Any]]: + method = http_method.upper() + try: + return self._handle(method, parts, query, body) + except RemoteDesktopHostError as exc: + return ERROR_STATUS.get(exc.code, 503), {"error": exc.code} + except RemoteDesktopRequestError as exc: + return exc.status, {"error": exc.code} + + def _handle( + self, + method: str, + parts: list[str], + query: dict[str, list[str]], + body: dict[str, Any] | None, + ) -> tuple[int, dict[str, Any]]: + if method == "GET" and parts == ["status"]: + status = self._native_json("status", {}) + status.setdefault("iceServers", self._ice_servers()) + status.setdefault("capabilities", {})["relayAvailable"] = self._relay_available() + return 200, status + + if method == "GET" and parts == ["frame"]: + response = self.host_client.call("frame.capture", {}) + status = int(response.get("status") or 500) + error_code = response.get("errorCode") + if status >= 400 or error_code: + raise RemoteDesktopHostError(str(error_code or "host_unavailable")) + raw_payload = response.get("payload") + if not isinstance(raw_payload, (bytes, bytearray)) or not raw_payload: + raise RemoteDesktopHostError("malformed_response") + return 200, { + "_raw": bytes(raw_payload), + "_content_type": "image/jpeg", + "_cache_control": "no-store, max-age=0", + } + + if method == "POST" and parts == ["input"]: + payload = _input_body(body) + _validate_identifier(payload["sessionId"]) + _positive_int(payload["sequence"]) + if payload["kind"] not in REMOTE_INPUT_KINDS: + raise RemoteDesktopRequestError(400, "invalid_request") + for key in ("x", "y", "deltaX", "deltaY"): + if payload.get(key) is not None and not isinstance(payload[key], (int, float)): + raise RemoteDesktopRequestError(400, "invalid_request") + for key in ("button", "keyCode"): + if payload.get(key) is not None and not isinstance(payload[key], int): + raise RemoteDesktopRequestError(400, "invalid_request") + if payload.get("text") is not None and not isinstance(payload["text"], str): + raise RemoteDesktopRequestError(400, "invalid_request") + return 200, self._native_json("input.inject", payload) + + if method == "POST" and parts == ["pairing", "start"]: + payload = _strict_body(body, {"deviceId", "name", "publicKey"}) + _validate_identifier(payload["deviceId"]) + return 200, self._native_json("pairing.start", payload) + + if method == "POST" and parts == ["pairing", "complete"]: + payload = _strict_body(body, {"challengeId", "deviceId", "signature"}) + _validate_identifier(payload["challengeId"]) + _validate_identifier(payload["deviceId"]) + _require_base64(payload["signature"]) + return 200, self._native_json("pairing.complete", payload) + + if method == "GET" and parts == ["devices"]: + return 200, self._native_json("devices.list", {}) + + if method == "POST" and len(parts) == 3 and parts[0] == "devices" and parts[2] == "revoke": + device_id = _validate_identifier(parts[1]) + _strict_body(body, set()) + return 200, self._native_json("devices.revoke", {"deviceId": device_id}) + + if method == "POST" and parts == ["sessions", "nonce"]: + payload = _strict_body(body, {"deviceId"}) + _validate_identifier(payload["deviceId"]) + return 200, self._native_json("session.nonce", payload) + + if method == "POST" and parts == ["sessions"]: + payload = _strict_body(body, {"deviceId", "nonce", "signature"}) + _validate_identifier(payload["deviceId"]) + _validate_identifier(payload["nonce"]) + _require_base64(payload["signature"]) + return 200, self._native_json("session.start", payload) + + if method == "POST" and len(parts) == 3 and parts[0] == "sessions" and parts[2] == "signal": + session_id = _validate_identifier(parts[1]) + payload = _strict_body(body, {"sequence", "kind", "payload"}) + sequence = _positive_int(payload["sequence"]) + signal_kind = _validate_signal_kind(payload["kind"]) + _require_base64(payload["payload"]) + acknowledgement = self._append_signal(session_id, sequence, signal_kind, payload["payload"]) + native = self._native_json("session.signal", {"sessionId": session_id, **payload}) + signals = native.get("signals") + if isinstance(signals, list): + acknowledgement["signals"] = signals + return 200, acknowledgement + + if method == "GET" and len(parts) == 3 and parts[0] == "sessions" and parts[2] == "signal": + session_id = _validate_identifier(parts[1]) + after = _optional_int((query.get("after") or ["0"])[0]) + return 200, {"signals": self._signals_after(session_id, after)} + + if method == "POST" and len(parts) == 3 and parts[0] == "sessions" and parts[2] == "display": + session_id = _validate_identifier(parts[1]) + payload = _strict_body(body, {"displayId"}) + _validate_identifier(payload["displayId"]) + return 200, self._native_json("session.display", {"sessionId": session_id, **payload}) + + if method == "POST" and len(parts) == 3 and parts[0] == "sessions" and parts[2] == "clipboard": + session_id = _validate_identifier(parts[1]) + payload = _strict_body(body, {"direction", "text"}) + if payload["direction"] not in {"send", "receive"}: + raise RemoteDesktopRequestError(400, "invalid_request") + if not isinstance(payload["text"], str): + raise RemoteDesktopRequestError(400, "invalid_request") + if len(payload["text"].encode("utf-8")) > MAX_CLIPBOARD_TEXT_BYTES: + raise RemoteDesktopRequestError(413, "clipboard_too_large") + return 200, self._native_json("session.clipboard", {"sessionId": session_id, **payload}) + + if method == "POST" and len(parts) == 3 and parts[0] == "sessions" and parts[2] == "disconnect": + session_id = _validate_identifier(parts[1]) + _strict_body(body, set()) + with self._signal_lock: + self._signals.pop(session_id, None) + self._last_signal_sequence.pop(session_id, None) + return 200, self._native_json("session.end", {"sessionId": session_id}) + + raise RemoteDesktopRequestError(404, "not_found") + + def _native_json(self, method: str, payload: dict[str, Any]) -> dict[str, Any]: + response = self.host_client.call(method, payload) + status = int(response.get("status") or 500) + error_code = response.get("errorCode") + if status >= 400 or error_code: + raise RemoteDesktopHostError(str(error_code or "host_unavailable")) + + raw_payload = response.get("payload") + if raw_payload in (None, b""): + return {"ok": True} + try: + decoded = json.loads(raw_payload.decode("utf-8")) + except (AttributeError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RemoteDesktopHostError("malformed_response") from exc + if not isinstance(decoded, dict): + raise RemoteDesktopHostError("malformed_response") + return decoded + + def _append_signal(self, session_id: str, sequence: int, kind: str, payload: str) -> dict[str, Any]: + with self._signal_lock: + last_sequence = self._last_signal_sequence.get(session_id, 0) + if sequence <= last_sequence: + raise RemoteDesktopRequestError(409, "sequence_replay") + signal = {"sequence": sequence, "kind": kind, "payload": payload} + queue = self._signals.setdefault(session_id, []) + queue.append(signal) + del queue[:-self.signal_queue_limit] + self._last_signal_sequence[session_id] = sequence + return {"sequence": sequence} + + def _signals_after(self, session_id: str, after: int) -> list[dict[str, Any]]: + with self._signal_lock: + return [ + dict(signal) + for signal in self._signals.get(session_id, []) + if int(signal["sequence"]) > after + ] + + def _relay_available(self) -> bool: + return bool(os.environ.get("CODEPILOT_TURN_KEY_ID") and os.environ.get("CODEPILOT_TURN_API_TOKEN")) + + def _ice_servers(self) -> list[dict[str, Any]]: + if not self._relay_available(): + return list(STUN_ONLY_ICE_SERVERS) + key_id = os.environ["CODEPILOT_TURN_KEY_ID"] + api_token = os.environ["CODEPILOT_TURN_API_TOKEN"] + request = urllib.request.Request( + f"https://rtc.live.cloudflare.com/v1/turn/keys/{key_id}/credentials/generate-ice-servers", + data=json.dumps({"ttl": 300}, separators=(",", ":")).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with self.turn_urlopen(request, timeout=5) as response: + raw = response.read() + except (OSError, urllib.error.URLError) as exc: + raise RemoteDesktopHostError("turn_unavailable") from exc + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RemoteDesktopHostError("turn_unavailable") from exc + ice_servers = payload.get("iceServers") if isinstance(payload, dict) else None + if not isinstance(ice_servers, list): + raise RemoteDesktopHostError("turn_unavailable") + return ice_servers + + +class RemoteDesktopRequestError(ValueError): + def __init__(self, status: int, code: str): + self.status = status + self.code = code + super().__init__(code) + + +def _strict_body(body: dict[str, Any] | None, allowed_keys: set[str]) -> dict[str, Any]: + if body is None: + body = {} + if not isinstance(body, dict): + raise RemoteDesktopRequestError(400, "invalid_request") + keys = set(body) + if keys != allowed_keys: + raise RemoteDesktopRequestError(400, "invalid_request") + return dict(body) + + +def _input_body(body: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(body, dict): + raise RemoteDesktopRequestError(400, "invalid_request") + keys = set(body) + if not {"sessionId", "sequence", "kind"}.issubset(keys) or not keys.issubset(REMOTE_INPUT_KEYS): + raise RemoteDesktopRequestError(400, "invalid_request") + return dict(body) + + +def _validate_identifier(value: Any) -> str: + if not isinstance(value, str): + raise RemoteDesktopRequestError(400, "invalid_identifier") + value = value.strip() + if not value or len(value) > IDENTIFIER_MAX_LENGTH: + raise RemoteDesktopRequestError(400, "invalid_identifier") + for character in value: + if not (character.isalnum() or character in {"-", "_", ".", ":"}): + raise RemoteDesktopRequestError(400, "invalid_identifier") + return value + + +def _require_base64(value: Any) -> str: + if not isinstance(value, str): + raise RemoteDesktopRequestError(400, "invalid_base64") + try: + base64.b64decode(value.encode("ascii"), validate=True) + except (UnicodeError, binascii.Error) as exc: + raise RemoteDesktopRequestError(400, "invalid_base64") from exc + return value + + +def _positive_int(value: Any) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise RemoteDesktopRequestError(400, "invalid_request") + return value + + +def _optional_int(value: Any) -> int: + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise RemoteDesktopRequestError(400, "invalid_request") from exc + return max(0, parsed) + + +def _validate_signal_kind(value: Any) -> str: + if value not in {"offer", "answer", "ice"}: + raise RemoteDesktopRequestError(400, "invalid_request") + return str(value) diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 46ffc121..ec80e6a8 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -40,6 +40,20 @@ def send_turn_completion(self, devices, notification): self.sent.append((devices, notification)) +class RecordingLocalWebFetcher: + def __init__(self): + self.urls = [] + + def __call__(self, url): + self.urls.append(url) + return 200, {"Content-Type": "text/html; charset=utf-8"}, ( + b'' + b'' + b'Settings' + b"" + ) + + class FakeRemoteLoginProcess: def __init__(self, codex_home: Path, auth_url: str): self.codex_home = codex_home @@ -75,6 +89,16 @@ def complete(self, auth_payload='{"account":"new-main"}'): class AppServerClientTests(unittest.TestCase): + def setUp(self): + self._original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + self._switcher_home_tempdir = tempfile.TemporaryDirectory() + gateway.DEFAULT_SWITCHER_HOME = Path(self._switcher_home_tempdir.name) + gateway.DEFAULT_SWITCHER_HOME.mkdir(parents=True, exist_ok=True) + + def tearDown(self): + gateway.DEFAULT_SWITCHER_HOME = self._original_switcher_home + self._switcher_home_tempdir.cleanup() + def test_codex_child_env_prepends_homebrew_path_and_sets_codex_home(self): old_env = dict(gateway.os.environ) try: @@ -778,6 +802,51 @@ def phone_message_ids(thread_id): class GatewayStateTests(unittest.TestCase): + def setUp(self): + self._original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + self._switcher_home_tempdir = tempfile.TemporaryDirectory() + gateway.DEFAULT_SWITCHER_HOME = Path(self._switcher_home_tempdir.name) + gateway.DEFAULT_SWITCHER_HOME.mkdir(parents=True, exist_ok=True) + + def tearDown(self): + gateway.DEFAULT_SWITCHER_HOME = self._original_switcher_home + self._switcher_home_tempdir.cleanup() + + def test_public_health_exposes_safe_gateway_status(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + 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) + + health = state.public_health() + finally: + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + + self.assertTrue(health["gateway"]["running"]) + self.assertEqual(health["accounts"]["active"], "main") + self.assertIn("auth", health["accounts"]) + self.assertIn("notifications", health) + self.assertIn("remoteDesktop", health) + self.assertIn("localWeb", health) + self.assertNotIn("secret-token", json.dumps(health)) + + def test_error_payload_has_stable_code_message_and_recovery(self): + payload = gateway.error_payload( + "local_web_invalid_target", + "Only localhost URLs can be opened.", + "Open a localhost, 127.0.0.1, or ::1 URL from the iPhone app.", + ) + + self.assertEqual(payload["error"]["code"], "local_web_invalid_target") + self.assertEqual(payload["error"]["message"], "Only localhost URLs can be opened.") + self.assertIn("localhost", payload["error"]["recovery"]) + def test_resolve_requested_file_path_accepts_absolute_file_paths(self): with tempfile.TemporaryDirectory() as tmp: file_path = Path(tmp) / "created-by-codex.txt" @@ -803,6 +872,41 @@ def test_file_metadata_reports_download_details(self): self.assertEqual(metadata["mimeType"], "text/plain") self.assertEqual(metadata["size"], 5) + def test_local_web_session_rejects_non_loopback_url(self): + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + + with self.assertRaises(ValueError): + state.start_local_web_session("https://example.com") + + def test_local_web_session_returns_gateway_path_for_localhost_url(self): + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + + session = state.start_local_web_session("http://localhost:3000/dashboard?tab=logs") + + self.assertEqual(session["targetOrigin"], "http://127.0.0.1:3000") + self.assertRegex(session["path"], r"^/api/local-web/[^/]+/dashboard\?tab=logs$") + self.assertGreater(session["expiresAt"], 0) + + def test_local_web_session_proxies_localhost_content_and_rewrites_same_origin_links(self): + fetcher = RecordingLocalWebFetcher() + state = GatewayState( + Path("/tmp/codex"), + "token", + Path("/missing-codex"), + False, + local_web_fetcher=fetcher, + ) + session = state.start_local_web_session("http://localhost:3000/dashboard") + session_id = session["sessionId"] + + response = state.proxy_local_web_session(session_id, ["dashboard"], "") + + self.assertEqual(fetcher.urls, ["http://127.0.0.1:3000/dashboard"]) + self.assertEqual(response["status"], 200) + self.assertEqual(response["contentType"], "text/html; charset=utf-8") + self.assertIn(b'src="/api/local-web/', response["body"]) + self.assertIn(b'href="/api/local-web/', response["body"]) + def test_prepare_workspace_can_create_new_project_directory(self): with tempfile.TemporaryDirectory() as tmp: codex_home = Path(tmp) / "codex" @@ -1011,6 +1115,52 @@ def test_account_statuses_include_profiles_usage_and_active_marker(self): finally: gateway.DEFAULT_SWITCHER_HOME = old_home + def test_switch_account_persists_refreshed_active_auth_before_installing_next_account(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + codex_home = tmp_path / "codex" + codex_home.mkdir() + switcher_home = tmp_path / "switcher" + accounts_dir = switcher_home / "accounts" + (accounts_dir / "Main").mkdir(parents=True) + (accounts_dir / "Free").mkdir(parents=True) + active_auth = { + "last_refresh": "2026-06-24T08:00:00Z", + "tokens": {"account_id": "main-account", "access_token": "active-refreshed"}, + } + main_profile_auth = { + "last_refresh": "2026-06-23T08:00:00Z", + "tokens": {"account_id": "main-account", "access_token": "old-profile"}, + } + free_profile_auth = { + "last_refresh": "2026-06-24T07:00:00Z", + "tokens": {"account_id": "free-account", "access_token": "free"}, + } + (codex_home / "auth.json").write_text(json.dumps(active_auth), encoding="utf-8") + (accounts_dir / "Main" / "auth.json").write_text(json.dumps(main_profile_auth), encoding="utf-8") + (accounts_dir / "Free" / "auth.json").write_text(json.dumps(free_profile_auth), encoding="utf-8") + (switcher_home / "active-account.txt").write_text("Main\n", encoding="utf-8") + + old_home = gateway.DEFAULT_SWITCHER_HOME + gateway.DEFAULT_SWITCHER_HOME = switcher_home + with gateway.JOBS_LOCK: + old_jobs = dict(gateway.JOBS) + gateway.JOBS.clear() + try: + state = GatewayState(codex_home, "token", Path("/missing-codex"), False) + + state.switch_account("Free") + + persisted_main = json.loads((accounts_dir / "Main" / "auth.json").read_text(encoding="utf-8")) + installed_active = json.loads((codex_home / "auth.json").read_text(encoding="utf-8")) + self.assertEqual(persisted_main["tokens"]["access_token"], "active-refreshed") + self.assertEqual(installed_active["tokens"]["access_token"], "free") + finally: + with gateway.JOBS_LOCK: + gateway.JOBS.clear() + gateway.JOBS.update(old_jobs) + gateway.DEFAULT_SWITCHER_HOME = old_home + def test_parse_mcp_list_output_marks_reconnectable_auth_states(self): state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) output = """Name Url Bearer Token Env Var Status Auth diff --git a/gateway/test_remote_desktop_gateway.py b/gateway/test_remote_desktop_gateway.py new file mode 100644 index 00000000..af2e460b --- /dev/null +++ b/gateway/test_remote_desktop_gateway.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +import base64 +import json +import os +import socket +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +try: + from .remote_desktop_gateway import NativeHostClient, RemoteDesktopGateway, RemoteDesktopHostError +except ImportError: + from remote_desktop_gateway import NativeHostClient, RemoteDesktopGateway, RemoteDesktopHostError + + +class UnixSocketServer: + def __init__(self, socket_path: Path, handler): + self.socket_path = socket_path + self.handler = handler + self._thread = None + self._ready = threading.Event() + self._stop = threading.Event() + + def __enter__(self): + self.socket_path.parent.mkdir(parents=True, exist_ok=True) + try: + os.unlink(self.socket_path) + except FileNotFoundError: + pass + self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._server.bind(str(self.socket_path)) + self._server.listen(1) + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + self._ready.wait(1) + return self + + def __exit__(self, exc_type, exc, tb): + self._stop.set() + try: + self._server.close() + finally: + try: + os.unlink(self.socket_path) + except FileNotFoundError: + pass + if self._thread is not None: + self._thread.join(1) + + def _serve(self): + self._ready.set() + while not self._stop.is_set(): + try: + conn, _ = self._server.accept() + except OSError: + return + with conn: + self.handler(conn) + + +class NativeHostClientTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.socket_path = Path(self.tempdir.name) / "remote-desktop.sock" + + def tearDown(self): + self.tempdir.cleanup() + + def test_call_succeeds_and_decodes_payload(self): + response_payload = base64.b64encode(json.dumps({"ok": True}).encode("utf-8")).decode("ascii") + expected_request = {} + + def handler(conn): + line = _read_line(conn) + expected_request.update(json.loads(line)) + response = { + "id": expected_request["id"], + "status": 200, + "payload": response_payload, + "errorCode": None, + } + conn.sendall(json.dumps(response).encode("utf-8") + b"\n") + + with UnixSocketServer(self.socket_path, handler): + client = NativeHostClient(self.socket_path, timeout_seconds=1.0, max_response_bytes=1024) + response = client.call("status", {"scope": "health"}) + + self.assertEqual(expected_request["method"], "status") + self.assertEqual( + expected_request["payload"], + base64.b64encode(json.dumps({"scope": "health"}, separators=(",", ":")).encode("utf-8")).decode("ascii"), + ) + self.assertEqual(response["status"], 200) + self.assertEqual(json.loads(response["payload"].decode("utf-8")), {"ok": True}) + + def test_call_uses_empty_payload_envelope_when_payload_is_missing(self): + expected_request = {} + + def handler(conn): + line = _read_line(conn) + expected_request.update(json.loads(line)) + response = { + "id": expected_request["id"], + "status": 200, + "payload": "", + "errorCode": None, + } + conn.sendall(json.dumps(response).encode("utf-8") + b"\n") + + with UnixSocketServer(self.socket_path, handler): + client = NativeHostClient(self.socket_path, timeout_seconds=1.0, max_response_bytes=1024) + response = client.call("status") + + self.assertEqual(expected_request["payload"], "") + self.assertEqual(response["payload"], b"") + + def test_call_accepts_case_changed_uuid_from_swift_host(self): + response_payload = base64.b64encode(json.dumps({"ok": True}).encode("utf-8")).decode("ascii") + + def handler(conn): + request = json.loads(_read_line(conn)) + response = { + "id": request["id"].upper(), + "status": 200, + "payload": response_payload, + "errorCode": None, + } + conn.sendall(json.dumps(response).encode("utf-8") + b"\n") + + with UnixSocketServer(self.socket_path, handler): + client = NativeHostClient(self.socket_path, timeout_seconds=1.0, max_response_bytes=1024) + response = client.call("status", {}) + + self.assertEqual(response["status"], 200) + self.assertEqual(json.loads(response["payload"].decode("utf-8")), {"ok": True}) + + def test_call_maps_connection_failure_to_safe_error(self): + client = NativeHostClient(self.socket_path, timeout_seconds=0.1) + + with self.assertRaises(RemoteDesktopHostError) as raised: + client.call("status", {}) + + self.assertEqual(raised.exception.code, "host_unavailable") + self.assertNotIn(self.socket_path.name, str(raised.exception)) + + def test_call_times_out_when_host_is_silent(self): + def handler(conn): + _read_line(conn) + time.sleep(0.5) + + with UnixSocketServer(self.socket_path, handler): + client = NativeHostClient(self.socket_path, timeout_seconds=0.05) + + with self.assertRaises(RemoteDesktopHostError) as raised: + client.call("status", {}) + + self.assertEqual(raised.exception.code, "timeout") + + def test_call_rejects_malformed_response(self): + def handler(conn): + _read_line(conn) + conn.sendall(b'{"status":200}\n') + + with UnixSocketServer(self.socket_path, handler): + client = NativeHostClient(self.socket_path, timeout_seconds=1.0) + + with self.assertRaises(RemoteDesktopHostError) as raised: + client.call("status", {}) + + self.assertEqual(raised.exception.code, "malformed_response") + + def test_call_rejects_oversized_response(self): + def handler(conn): + _read_line(conn) + conn.sendall(b'{"id":"1","status":200,"payload":"' + b"x" * 256 + b'","errorCode":null}\n') + + with UnixSocketServer(self.socket_path, handler): + client = NativeHostClient(self.socket_path, timeout_seconds=1.0, max_response_bytes=128) + + with self.assertRaises(RemoteDesktopHostError) as raised: + client.call("status", {}) + + self.assertEqual(raised.exception.code, "response_too_large") + + +class FakeNativeHostClient: + def __init__(self): + self.calls = [] + self.fail_code = None + self.responses = {} + + def call(self, method, payload=None): + self.calls.append((method, payload)) + if self.fail_code: + raise RemoteDesktopHostError(self.fail_code) + response = self.responses.get(method) + if response is None: + response = {"ok": True, "method": method, "payload": payload} + if isinstance(response, dict) and {"status", "payload", "errorCode"}.issubset(response): + return response + return { + "id": "native-response", + "status": 200, + "payload": json.dumps(response).encode("utf-8"), + "errorCode": None, + } + + +class RemoteDesktopGatewayTests(unittest.TestCase): + def test_rejects_unknown_body_keys_and_malformed_identifiers(self): + host = FakeNativeHostClient() + gateway = RemoteDesktopGateway(host_client=host) + + status, payload = gateway.handle("POST", ["pairing", "start"], {}, {"deviceId": "phone-1", "extra": True}) + self.assertEqual(status, 400) + self.assertEqual(payload["error"], "invalid_request") + + status, payload = gateway.handle("POST", ["devices", "../bad", "revoke"], {}, {}) + self.assertEqual(status, 400) + self.assertEqual(payload["error"], "invalid_identifier") + self.assertEqual(host.calls, []) + + def test_maps_native_errors_to_stable_public_statuses(self): + host = FakeNativeHostClient() + host.fail_code = "controller_busy" + gateway = RemoteDesktopGateway(host_client=host) + + status, payload = gateway.handle("POST", ["sessions"], {}, { + "deviceId": "phone-1", + "nonce": "nonce-1", + "signature": base64.b64encode(b"sig").decode("ascii"), + }) + + self.assertEqual(status, 409) + self.assertEqual(payload["error"], "controller_busy") + self.assertNotIn("host.sock", json.dumps(payload)) + + def test_pairing_and_session_routes_forward_validated_payloads(self): + host = FakeNativeHostClient() + gateway = RemoteDesktopGateway(host_client=host) + + status, payload = gateway.handle("POST", ["pairing", "complete"], {}, { + "challengeId": "challenge-1", + "deviceId": "phone-1", + "signature": base64.b64encode(b"proof").decode("ascii"), + }) + + self.assertEqual(status, 200) + self.assertTrue(payload["ok"]) + self.assertEqual(host.calls[0][0], "pairing.complete") + self.assertEqual(host.calls[0][1]["signature"], base64.b64encode(b"proof").decode("ascii")) + + def test_rejects_invalid_signature_base64(self): + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient()) + + status, payload = gateway.handle("POST", ["sessions"], {}, { + "deviceId": "phone-1", + "nonce": "nonce-1", + "signature": "not base64?", + }) + + self.assertEqual(status, 400) + self.assertEqual(payload["error"], "invalid_base64") + + def test_signal_queue_is_bounded_monotonic_and_cleared_on_disconnect(self): + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient(), signal_queue_limit=2) + + for sequence in [1, 2, 3]: + status, payload = gateway.handle("POST", ["sessions", "session-1", "signal"], {}, { + "sequence": sequence, + "kind": "offer", + "payload": base64.b64encode(f"signal-{sequence}".encode("utf-8")).decode("ascii"), + }) + self.assertEqual(status, 200) + self.assertEqual(payload["sequence"], sequence) + + status, payload = gateway.handle("POST", ["sessions", "session-1", "signal"], {}, { + "sequence": 3, + "kind": "offer", + "payload": base64.b64encode(b"replay").decode("ascii"), + }) + self.assertEqual(status, 409) + self.assertEqual(payload["error"], "sequence_replay") + + status, payload = gateway.handle("GET", ["sessions", "session-1", "signal"], {"after": ["0"]}, None) + self.assertEqual(status, 200) + self.assertEqual([item["sequence"] for item in payload["signals"]], [2, 3]) + + status, _ = gateway.handle("POST", ["sessions", "session-1", "disconnect"], {}, {}) + self.assertEqual(status, 200) + status, payload = gateway.handle("GET", ["sessions", "session-1", "signal"], {"after": ["0"]}, None) + self.assertEqual(payload["signals"], []) + + def test_clipboard_requires_explicit_text_under_size_limit(self): + host = FakeNativeHostClient() + gateway = RemoteDesktopGateway(host_client=host) + + status, payload = gateway.handle("POST", ["sessions", "session-1", "clipboard"], {}, { + "direction": "send", + "text": "hello", + }) + self.assertEqual(status, 200) + self.assertEqual(host.calls[0][0], "session.clipboard") + + status, payload = gateway.handle("POST", ["sessions", "session-1", "clipboard"], {}, { + "direction": "send", + "text": "x" * (1_048_576 + 1), + }) + self.assertEqual(status, 413) + self.assertEqual(payload["error"], "clipboard_too_large") + + status, payload = gateway.handle("POST", ["sessions", "session-1", "clipboard"], {}, { + "direction": "send", + "text": 42, + }) + self.assertEqual(status, 400) + self.assertEqual(payload["error"], "invalid_request") + + def test_status_returns_stun_only_when_turn_environment_is_unset(self): + old_env = dict(os.environ) + try: + os.environ.pop("CODEPILOT_TURN_KEY_ID", None) + os.environ.pop("CODEPILOT_TURN_API_TOKEN", None) + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient()) + + status, payload = gateway.handle("GET", ["status"], {}, None) + + self.assertEqual(status, 200) + self.assertFalse(payload["capabilities"]["relayAvailable"]) + self.assertEqual(payload["iceServers"], [{"urls": ["stun:stun.l.google.com:19302"]}]) + finally: + os.environ.clear() + os.environ.update(old_env) + + def test_frame_returns_raw_jpeg_payload(self): + host = FakeNativeHostClient() + host.responses["frame.capture"] = { + "status": 200, + "payload": b"\xff\xd8jpeg\xff\xd9", + "errorCode": None, + } + gateway = RemoteDesktopGateway(host_client=host) + + status, payload = gateway.handle("GET", ["frame"], {}, None) + + self.assertEqual(status, 200) + self.assertEqual(payload["_raw"], b"\xff\xd8jpeg\xff\xd9") + self.assertEqual(payload["_content_type"], "image/jpeg") + + def test_input_forwards_validated_event_to_native_host(self): + host = FakeNativeHostClient() + gateway = RemoteDesktopGateway(host_client=host) + event = { + "sessionId": "gateway-session", + "sequence": 7, + "kind": "pointer", + "x": 0.25, + "y": 0.75, + "button": None, + "keyCode": None, + "text": None, + "deltaX": None, + "deltaY": None, + } + + status, payload = gateway.handle("POST", ["input"], {}, event) + + self.assertEqual(status, 200) + self.assertTrue(payload["ok"]) + self.assertEqual(host.calls[-1], ("input.inject", event)) + + def test_input_accepts_swift_payload_with_omitted_optional_fields(self): + host = FakeNativeHostClient() + gateway = RemoteDesktopGateway(host_client=host) + event = { + "sessionId": "swift-session", + "sequence": 1, + "kind": "pointer", + "x": 0.4, + "y": 0.6, + } + + status, payload = gateway.handle("POST", ["input"], {}, event) + + self.assertEqual(status, 200) + self.assertTrue(payload["ok"]) + self.assertEqual(host.calls[-1], ("input.inject", event)) + + def test_input_rejects_unknown_fields(self): + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient()) + + status, payload = gateway.handle("POST", ["input"], {}, { + "sessionId": "gateway-session", + "sequence": 1, + "kind": "pointer", + "x": 0.5, + "y": 0.5, + "button": None, + "keyCode": None, + "text": None, + "deltaX": None, + "deltaY": None, + "unexpected": True, + }) + + self.assertEqual(status, 400) + self.assertEqual(payload, {"error": "invalid_request"}) + + def test_status_generates_cloudflare_turn_ice_servers_when_configured(self): + old_env = dict(os.environ) + captured = {} + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps({ + "iceServers": [{ + "urls": ["turn:relay.example.com:3478"], + "username": "generated-user", + "credential": "generated-password", + }] + }).encode("utf-8") + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["headers"] = dict(request.header_items()) + captured["body"] = json.loads(request.data.decode("utf-8")) + captured["timeout"] = timeout + return FakeResponse() + + try: + os.environ["CODEPILOT_TURN_KEY_ID"] = "turn-key-id" + os.environ["CODEPILOT_TURN_API_TOKEN"] = "secret-token" + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient(), turn_urlopen=fake_urlopen) + + status, payload = gateway.handle("GET", ["status"], {}, None) + + self.assertEqual(status, 200) + self.assertTrue(payload["capabilities"]["relayAvailable"]) + self.assertEqual(payload["iceServers"][0]["username"], "generated-user") + self.assertEqual(captured["url"], "https://rtc.live.cloudflare.com/v1/turn/keys/turn-key-id/credentials/generate-ice-servers") + self.assertEqual(captured["headers"]["Authorization"], "Bearer secret-token") + self.assertEqual(captured["body"], {"ttl": 300}) + self.assertEqual(captured["timeout"], 5) + self.assertNotIn("secret-token", json.dumps(payload)) + finally: + os.environ.clear() + os.environ.update(old_env) + + +class RemoteDesktopHTTPRouteTests(unittest.TestCase): + def test_remote_routes_require_bearer_auth(self): + try: + from . import codex_phone_gateway as phone_gateway + except ImportError: + import codex_phone_gateway as phone_gateway + + state = phone_gateway.GatewayState(Path(self._tempdir().name), "secret", Path("/missing-codex"), False) + server = phone_gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + + url = f"http://127.0.0.1:{server.server_port}/api/remote/status" + with self.assertRaises(urllib.error.HTTPError) as raised: + urllib.request.urlopen(url, timeout=2) + + self.assertEqual(raised.exception.code, 401) + + def test_remote_http_route_forwards_after_auth(self): + try: + from . import codex_phone_gateway as phone_gateway + except ImportError: + import codex_phone_gateway as phone_gateway + + class FakeRemoteDesktopGateway: + def handle(self, method, parts, query, body): + return 200, {"method": method, "parts": parts, "body": body} + + tempdir = self._tempdir() + state = phone_gateway.GatewayState( + Path(tempdir.name), + "secret", + Path("/missing-codex"), + False, + remote_desktop_gateway=FakeRemoteDesktopGateway(), + ) + server = phone_gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + + request = urllib.request.Request( + f"http://127.0.0.1:{server.server_port}/api/remote/pairing/start", + data=json.dumps({"deviceId": "phone-1", "name": "Phone", "publicKey": "pk"}).encode("utf-8"), + headers={"Authorization": "Bearer secret", "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=2) as response: + payload = json.loads(response.read().decode("utf-8")) + + self.assertEqual(payload["method"], "POST") + self.assertEqual(payload["parts"], ["pairing", "start"]) + self.assertEqual(payload["body"]["deviceId"], "phone-1") + + def test_remote_http_route_rejects_oversized_body(self): + try: + from . import codex_phone_gateway as phone_gateway + except ImportError: + import codex_phone_gateway as phone_gateway + + tempdir = self._tempdir() + state = phone_gateway.GatewayState( + Path(tempdir.name), + "secret", + Path("/missing-codex"), + False, + remote_desktop_gateway=object(), + ) + server = phone_gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + + with socket.create_connection(("127.0.0.1", server.server_port), timeout=2) as conn: + request = ( + "POST /api/remote/pairing/start HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Authorization: Bearer secret\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 1048577\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("ascii") + conn.sendall(request) + response = conn.recv(1024) + + self.assertIn(b"413", response.splitlines()[0]) + + def _tempdir(self): + tempdir = tempfile.TemporaryDirectory() + self.addCleanup(tempdir.cleanup) + return tempdir + + +def _read_line(conn): + buffer = bytearray() + while True: + chunk = conn.recv(1024) + if not chunk: + break + buffer.extend(chunk) + if b"\n" in chunk: + break + return bytes(buffer).split(b"\n", 1)[0].decode("utf-8") + + +if __name__ == "__main__": + unittest.main() diff --git a/ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj b/ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj index 6c71f39a..645e3dd5 100644 --- a/ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj +++ b/ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj @@ -10,18 +10,62 @@ 8F4E51112F42100100C0D3C1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F4E51102F42100100C0D3C1 /* Assets.xcassets */; }; 8F4E51212F42100100C0D3C1 /* ChatAutoScrollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E51202F42100100C0D3C1 /* ChatAutoScrollState.swift */; }; 8F4E51002F41000100C0D3C1 /* CodexPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E50FF2F41000100C0D3C1 /* CodexPhoneApp.swift */; }; + 8F4E52012F43000100C0D3C1 /* RemoteDesktopModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E52002F43000100C0D3C1 /* RemoteDesktopModels.swift */; }; + 8F4E52032F43000100C0D3C1 /* RemoteDesktopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E52022F43000100C0D3C1 /* RemoteDesktopTests.swift */; }; + 8F4E52052F43000100C0D3C1 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = 8F4E52042F43000100C0D3C1 /* WebRTC */; }; + 8F4E53012F44000100C0D3C1 /* RemoteDeviceIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53002F44000100C0D3C1 /* RemoteDeviceIdentity.swift */; }; + 8F4E53032F44000100C0D3C1 /* RemoteDesktopAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53022F44000100C0D3C1 /* RemoteDesktopAPI.swift */; }; + 8F4E53052F44000100C0D3C1 /* RemotePairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53042F44000100C0D3C1 /* RemotePairingView.swift */; }; + 8F4E53112F45000100C0D3C1 /* RemoteInputMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53102F45000100C0D3C1 /* RemoteInputMapper.swift */; }; + 8F4E53132F45000100C0D3C1 /* RemoteDesktopSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53122F45000100C0D3C1 /* RemoteDesktopSession.swift */; }; + 8F4E53152F45000100C0D3C1 /* RemotePeerConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53142F45000100C0D3C1 /* RemotePeerConnection.swift */; }; + 8F4E53172F45000100C0D3C1 /* RemoteControlBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53162F45000100C0D3C1 /* RemoteControlBar.swift */; }; + 8F4E53192F45000100C0D3C1 /* RemoteDesktopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53182F45000100C0D3C1 /* RemoteDesktopView.swift */; }; + 8F4E53212F46000100C0D3C1 /* RemoteViewport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53202F46000100C0D3C1 /* RemoteViewport.swift */; }; + 8F4E53232F46000100C0D3C1 /* RemoteVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E53222F46000100C0D3C1 /* RemoteVideoView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8F4E520D2F43000100C0D3C1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8F4E50F82F41000100C0D3C1 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8F4E50FA2F41000100C0D3C1; + remoteInfo = CodexPhone; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 8F4E51202F42100100C0D3C1 /* ChatAutoScrollState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAutoScrollState.swift; sourceTree = ""; }; 8F4E51222F42100100C0D3C1 /* CodexPhone.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodexPhone.entitlements; sourceTree = ""; }; 8F4E50FF2F41000100C0D3C1 /* CodexPhoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexPhoneApp.swift; sourceTree = ""; }; 8F4E51012F41000100C0D3C1 /* CodexPhone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodexPhone.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8F4E51102F42100100C0D3C1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 8F4E52002F43000100C0D3C1 /* RemoteDesktopModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDesktopModels.swift; sourceTree = ""; }; + 8F4E52022F43000100C0D3C1 /* RemoteDesktopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDesktopTests.swift; sourceTree = ""; }; + 8F4E52062F43000100C0D3C1 /* CodexPhoneTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodexPhoneTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8F4E53002F44000100C0D3C1 /* RemoteDeviceIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDeviceIdentity.swift; sourceTree = ""; }; + 8F4E53022F44000100C0D3C1 /* RemoteDesktopAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDesktopAPI.swift; sourceTree = ""; }; + 8F4E53042F44000100C0D3C1 /* RemotePairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePairingView.swift; sourceTree = ""; }; + 8F4E53102F45000100C0D3C1 /* RemoteInputMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteInputMapper.swift; sourceTree = ""; }; + 8F4E53122F45000100C0D3C1 /* RemoteDesktopSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDesktopSession.swift; sourceTree = ""; }; + 8F4E53142F45000100C0D3C1 /* RemotePeerConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePeerConnection.swift; sourceTree = ""; }; + 8F4E53162F45000100C0D3C1 /* RemoteControlBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteControlBar.swift; sourceTree = ""; }; + 8F4E53182F45000100C0D3C1 /* RemoteDesktopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDesktopView.swift; sourceTree = ""; }; + 8F4E53202F46000100C0D3C1 /* RemoteViewport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteViewport.swift; sourceTree = ""; }; + 8F4E53222F46000100C0D3C1 /* RemoteVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 8F4E50FC2F41000100C0D3C1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8F4E52052F43000100C0D3C1 /* WebRTC in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8F4E52082F43000100C0D3C1 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -35,6 +79,7 @@ isa = PBXGroup; children = ( 8F4E50FE2F41000100C0D3C1 /* CodexPhone */, + 8F4E52092F43000100C0D3C1 /* CodexPhoneTests */, 8F4E50FD2F41000100C0D3C1 /* Products */, ); sourceTree = ""; @@ -43,6 +88,7 @@ isa = PBXGroup; children = ( 8F4E51012F41000100C0D3C1 /* CodexPhone.app */, + 8F4E52062F43000100C0D3C1 /* CodexPhoneTests.xctest */, ); name = Products; sourceTree = ""; @@ -52,12 +98,39 @@ children = ( 8F4E50FF2F41000100C0D3C1 /* CodexPhoneApp.swift */, 8F4E51202F42100100C0D3C1 /* ChatAutoScrollState.swift */, + 8F4E520A2F43000100C0D3C1 /* RemoteDesktop */, 8F4E51222F42100100C0D3C1 /* CodexPhone.entitlements */, 8F4E51102F42100100C0D3C1 /* Assets.xcassets */, ); path = CodexPhone; sourceTree = ""; }; + 8F4E52092F43000100C0D3C1 /* CodexPhoneTests */ = { + isa = PBXGroup; + children = ( + 8F4E52022F43000100C0D3C1 /* RemoteDesktopTests.swift */, + ); + path = CodexPhoneTests; + sourceTree = ""; + }; + 8F4E520A2F43000100C0D3C1 /* RemoteDesktop */ = { + isa = PBXGroup; + children = ( + 8F4E52002F43000100C0D3C1 /* RemoteDesktopModels.swift */, + 8F4E53002F44000100C0D3C1 /* RemoteDeviceIdentity.swift */, + 8F4E53022F44000100C0D3C1 /* RemoteDesktopAPI.swift */, + 8F4E53042F44000100C0D3C1 /* RemotePairingView.swift */, + 8F4E53102F45000100C0D3C1 /* RemoteInputMapper.swift */, + 8F4E53122F45000100C0D3C1 /* RemoteDesktopSession.swift */, + 8F4E53142F45000100C0D3C1 /* RemotePeerConnection.swift */, + 8F4E53162F45000100C0D3C1 /* RemoteControlBar.swift */, + 8F4E53182F45000100C0D3C1 /* RemoteDesktopView.swift */, + 8F4E53202F46000100C0D3C1 /* RemoteViewport.swift */, + 8F4E53222F46000100C0D3C1 /* RemoteVideoView.swift */, + ); + path = RemoteDesktop; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -74,10 +147,31 @@ dependencies = ( ); name = CodexPhone; + packageProductDependencies = ( + 8F4E52042F43000100C0D3C1 /* WebRTC */, + ); productName = CodexPhone; productReference = 8F4E51012F41000100C0D3C1 /* CodexPhone.app */; productType = "com.apple.product-type.application"; }; + 8F4E52072F43000100C0D3C1 /* CodexPhoneTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8F4E52122F43000100C0D3C1 /* Build configuration list for PBXNativeTarget "CodexPhoneTests" */; + buildPhases = ( + 8F4E520B2F43000100C0D3C1 /* Sources */, + 8F4E52082F43000100C0D3C1 /* Frameworks */, + 8F4E520C2F43000100C0D3C1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8F4E520E2F43000100C0D3C1 /* PBXTargetDependency */, + ); + name = CodexPhoneTests; + productName = CodexPhoneTests; + productReference = 8F4E52062F43000100C0D3C1 /* CodexPhoneTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -102,11 +196,15 @@ Base, ); mainGroup = 8F4E50F72F41000100C0D3C1; + packageReferences = ( + 8F4E520F2F43000100C0D3C1 /* XCRemoteSwiftPackageReference "WebRTC" */, + ); productRefGroup = 8F4E50FD2F41000100C0D3C1 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 8F4E50FA2F41000100C0D3C1 /* CodexPhone */, + 8F4E52072F43000100C0D3C1 /* CodexPhoneTests */, ); }; /* End PBXProject section */ @@ -120,20 +218,54 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8F4E520C2F43000100C0D3C1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 8F4E50FB2F41000100C0D3C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8F4E51212F42100100C0D3C1 /* ChatAutoScrollState.swift in Sources */, + 8F4E51002F41000100C0D3C1 /* CodexPhoneApp.swift in Sources */, + 8F4E53032F44000100C0D3C1 /* RemoteDesktopAPI.swift in Sources */, + 8F4E53012F44000100C0D3C1 /* RemoteDeviceIdentity.swift in Sources */, + 8F4E53132F45000100C0D3C1 /* RemoteDesktopSession.swift in Sources */, + 8F4E53112F45000100C0D3C1 /* RemoteInputMapper.swift in Sources */, + 8F4E52012F43000100C0D3C1 /* RemoteDesktopModels.swift in Sources */, + 8F4E53152F45000100C0D3C1 /* RemotePeerConnection.swift in Sources */, + 8F4E53172F45000100C0D3C1 /* RemoteControlBar.swift in Sources */, + 8F4E53192F45000100C0D3C1 /* RemoteDesktopView.swift in Sources */, + 8F4E53212F46000100C0D3C1 /* RemoteViewport.swift in Sources */, + 8F4E53232F46000100C0D3C1 /* RemoteVideoView.swift in Sources */, + 8F4E53052F44000100C0D3C1 /* RemotePairingView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8F4E520B2F43000100C0D3C1 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8F4E51212F42100100C0D3C1 /* ChatAutoScrollState.swift in Sources */, - 8F4E51002F41000100C0D3C1 /* CodexPhoneApp.swift in Sources */, + 8F4E52032F43000100C0D3C1 /* RemoteDesktopTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8F4E520E2F43000100C0D3C1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8F4E50FA2F41000100C0D3C1 /* CodexPhone */; + targetProxy = 8F4E520D2F43000100C0D3C1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8F4E51042F41000100C0D3C1 /* Debug */ = { isa = XCBuildConfiguration; @@ -314,6 +446,36 @@ }; name = Release; }; + 8F4E52102F43000100C0D3C1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = io.codepilot.iOS.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CodexPhone.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CodexPhone"; + }; + name = Debug; + }; + 8F4E52112F43000100C0D3C1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = io.codepilot.iOS.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CodexPhone.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CodexPhone"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -335,7 +497,35 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8F4E52122F43000100C0D3C1 /* Build configuration list for PBXNativeTarget "CodexPhoneTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8F4E52102F43000100C0D3C1 /* Debug */, + 8F4E52112F43000100C0D3C1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 8F4E520F2F43000100C0D3C1 /* XCRemoteSwiftPackageReference "WebRTC" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/stasel/WebRTC.git"; + requirement = { + kind = exactVersion; + version = 149.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 8F4E52042F43000100C0D3C1 /* WebRTC */ = { + isa = XCSwiftPackageProductDependency; + package = 8F4E520F2F43000100C0D3C1 /* XCRemoteSwiftPackageReference "WebRTC" */; + productName = WebRTC; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 8F4E50F82F41000100C0D3C1 /* Project object */; } diff --git a/ios/CodexPhone/CodexPhone.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/CodexPhone/CodexPhone.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..d2207483 --- /dev/null +++ b/ios/CodexPhone/CodexPhone.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "7ca243407a3b132834ec3b117a3aea85a8f743151ef868a872c8bbf8082921f6", + "pins" : [ + { + "identity" : "webrtc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stasel/WebRTC.git", + "state" : { + "revision" : "ff4647dc67f00d037c199c69e1a1ee8ceda88236", + "version" : "149.0.0" + } + } + ], + "version" : 3 +} diff --git a/ios/CodexPhone/CodexPhone.xcodeproj/xcshareddata/xcschemes/CodexPhone.xcscheme b/ios/CodexPhone/CodexPhone.xcodeproj/xcshareddata/xcschemes/CodexPhone.xcscheme index 6520bf8b..a912111b 100644 --- a/ios/CodexPhone/CodexPhone.xcodeproj/xcshareddata/xcschemes/CodexPhone.xcscheme +++ b/ios/CodexPhone/CodexPhone.xcodeproj/xcshareddata/xcschemes/CodexPhone.xcscheme @@ -28,6 +28,18 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + Void = {} @AppStorage("gatewayURL") private var gatewayURL = "" @AppStorage("gatewayToken") private var gatewayToken = "" @State private var renamingThread: CodexThread? @@ -464,6 +524,13 @@ struct ThreadListView: View { AggregateCreditBar(accounts: model.accountStatuses) List { + Section { + Button(action: onRemoteDesktop) { + Label("Remote Desktop", systemImage: "desktopcomputer") + } + .accessibilityIdentifier("remoteDesktopHomeButton") + } + if !pinnedThreads.isEmpty { Section { ForEach(pinnedThreads) { thread in @@ -1319,24 +1386,35 @@ struct OpenableText: View { let gatewayURL: String let gatewayToken: String @State private var previewItem: FilePreviewItem? + @State private var localWebItem: LocalWebItem? @State private var openError: String? var body: some View { - Text(linkifiedAttributedString(text)) + Text(renderedMessageAttributedString(text)) .font(font) .foregroundStyle(foregroundColor) .textSelection(.enabled) .environment(\.openURL, OpenURLAction { url in - guard let path = remoteFilePath(from: url) else { - return .systemAction + if let path = remoteFilePath(from: url) { + Task { + await openRemoteFile(path) + } + return .handled } - Task { - await openRemoteFile(path) + if isMacLocalWebURL(url) { + Task { + await openLocalWebURL(url) + } + return .handled } - return .handled + return .systemAction }) .sheet(item: $previewItem) { item in - QuickLookPreview(url: item.url) + FilePreviewSheet(url: item.url) + .presentationDragIndicator(.visible) + } + .sheet(item: $localWebItem) { item in + LocalWebBrowserView(item: item) .ignoresSafeArea() } .alert("File Could Not Be Opened", isPresented: Binding( @@ -1362,6 +1440,20 @@ struct OpenableText: View { openError = error.localizedDescription } } + + @MainActor + private func openLocalWebURL(_ url: URL) async { + do { + let sessionURL = try await CodexGatewayClient().startLocalWebSession( + url: url, + baseURL: gatewayURL, + token: gatewayToken + ) + localWebItem = LocalWebItem(originalURL: url, sessionURL: sessionURL) + } catch { + openError = error.localizedDescription + } + } } struct FilePreviewItem: Identifiable { @@ -1369,6 +1461,73 @@ struct FilePreviewItem: Identifiable { let url: URL } +struct LocalWebItem: Identifiable { + let id = UUID() + let originalURL: URL + let sessionURL: URL +} + +struct FilePreviewSheet: View { + let url: URL + @Environment(\.dismiss) private var dismiss + @State private var dragOffset: CGFloat = 0 + + var body: some View { + ZStack(alignment: .top) { + QuickLookPreview(url: url) + .ignoresSafeArea() + + VStack(spacing: 8) { + Capsule() + .fill(.secondary.opacity(0.55)) + .frame(width: 42, height: 5) + .padding(.top, 10) + .accessibilityHidden(true) + + HStack { + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 30, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + .padding(.trailing, 14) + .accessibilityLabel("Close document") + } + } + .padding(.top, 4) + .background(.ultraThinMaterial.opacity(0.85)) + } + .offset(y: max(0, dragOffset)) + .gesture( + DragGesture(minimumDistance: 16) + .onChanged { value in + dragOffset = max(0, value.translation.height) + } + .onEnded { value in + if shouldDismissFilePreview(translation: value.translation, predictedEndTranslation: value.predictedEndTranslation) { + dismiss() + } else { + withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) { + dragOffset = 0 + } + } + } + ) + } +} + +func shouldDismissFilePreview(translation: CGSize, predictedEndTranslation: CGSize) -> Bool { + let downwardDistance = translation.height + let predictedDownwardDistance = predictedEndTranslation.height + let horizontalDistance = abs(translation.width) + return (downwardDistance > 90 || predictedDownwardDistance > 150) && downwardDistance > horizontalDistance +} + struct QuickLookPreview: UIViewControllerRepresentable { let url: URL @@ -1403,10 +1562,61 @@ struct QuickLookPreview: UIViewControllerRepresentable { } } +struct LocalWebBrowserView: View { + let item: LocalWebItem + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + LocalWebView(url: item.sessionURL) + .navigationTitle(item.originalURL.absoluteString) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { + dismiss() + } + } + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: item.originalURL) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share Original URL") + } + } + } + } +} + +struct LocalWebView: UIViewRepresentable { + let url: URL + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.allowsInlineMediaPlayback = true + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.allowsBackForwardNavigationGestures = true + webView.load(URLRequest(url: url)) + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} +} + private let remoteFileURLScheme = "codex-phone-file" -private func linkifiedAttributedString(_ text: String) -> AttributedString { - var attributed = AttributedString(text) +func renderedMessageAttributedString(_ text: String) -> AttributedString { + let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace, + failurePolicy: .returnPartiallyParsedIfPossible + ) + let attributed = (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text) + return linkifiedAttributedString(attributed) +} + +private func linkifiedAttributedString(_ source: AttributedString) -> AttributedString { + var attributed = source + let text = String(attributed.characters) let nsText = text as NSString let fullRange = NSRange(location: 0, length: nsText.length) var linkedRanges: [NSRange] = [] @@ -1474,7 +1684,7 @@ private func normalizedRemoteFilePath(_ raw: String) -> String { return trimmed.replacingOccurrences(of: #":\d+(?::\d+)?$"#, with: "", options: .regularExpression) } -private func remoteFilePreviewURL(path: String) -> URL? { +func remoteFilePreviewURL(path: String) -> URL? { var components = URLComponents() components.scheme = remoteFileURLScheme components.host = "open" @@ -1482,14 +1692,52 @@ private func remoteFilePreviewURL(path: String) -> URL? { return components.url } -private func remoteFilePath(from url: URL) -> String? { +func remoteFilePath(from url: URL) -> String? { guard url.scheme == remoteFileURLScheme, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return nil + return macAbsoluteFilePath(from: url) } return components.queryItems?.first(where: { $0.name == "path" })?.value } +func macAbsoluteFilePath(from url: URL) -> String? { + if url.isFileURL { + return normalizedRemoteFilePath(url.path) + } + + guard url.scheme == nil else { + return nil + } + + let path = url.path + guard path.hasPrefix("/") else { + return nil + } + return normalizedRemoteFilePath(path) +} + +func isMacLocalWebURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { + return false + } + guard let host = url.host?.lowercased() else { + return false + } + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +func localWebSessionURL(path: String, baseURL: String) -> URL? { + guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)), + var components = URLComponents(url: root, resolvingAgainstBaseURL: false) else { + return nil + } + let parsedPath = URLComponents(string: path) + components.path = parsedPath?.path ?? path + components.queryItems = parsedPath?.queryItems + components.fragment = nil + return components.url +} + struct JobStatusView: View { let job: CodexJob let gatewayURL: String @@ -1770,6 +2018,7 @@ struct SettingsView: View { @Binding var gatewayToken: String @ObservedObject var model: CodexPhoneModel @Environment(\.dismiss) private var dismiss + @AppStorage("gatewayConnectionKind") private var gatewayConnectionKind = GatewayConnectionKind.local.rawValue @State private var isTestingConnection = false @State private var connectionMessage = "" @State private var lastConnectedAt: Date? @@ -1779,6 +2028,17 @@ struct SettingsView: View { NavigationStack { Form { Section("Gateway") { + Picker("Connection", selection: $gatewayConnectionKind) { + ForEach(GatewayConnectionKind.allCases) { kind in + Text(kind.title).tag(kind.rawValue) + } + } + .pickerStyle(.segmented) + + Text(selectedConnectionKind.helpText) + .font(.caption) + .foregroundStyle(.secondary) + TextField("URL", text: $gatewayURL) .textInputAutocapitalization(.never) .keyboardType(.URL) @@ -1832,6 +2092,10 @@ struct SettingsView: View { !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var selectedConnectionKind: GatewayConnectionKind { + GatewayConnectionKind(rawValue: gatewayConnectionKind) ?? .local + } + @MainActor private func testConnection() async { isTestingConnection = true @@ -1858,6 +2122,8 @@ struct SettingsView: View { return "Cloudflare reached the hostname, but the gateway is not reachable behind it." case .http(let status): return "Gateway returned HTTP \(status)." + case .gateway(let payload): + return GatewayErrorPresenter.recovery(for: payload) case .server(let message): return message case .invalidResponse: @@ -3979,7 +4245,7 @@ final class CodexPhoneModel: ObservableObject { private func report(_ error: Error) { guard !isBenignCancellationError(error) else { return } guard !isMissingJobError(error) else { return } - errorMessage = error.localizedDescription + errorMessage = GatewayErrorPresenter.message(for: error) } private func refreshAccountStatusForQueueDecision(baseURL: String, token: String) async { @@ -4043,8 +4309,14 @@ final class CodexPhoneModel: ObservableObject { private func isAccountUnavailableError(_ error: Error) -> Bool { guard let gatewayError = error as? GatewayError else { return false } - guard case .server(let message) = gatewayError else { return false } - return isAccountUnavailableMessage(message) + switch gatewayError { + case .gateway(let payload): + return payload.code == "auth_stale" || payload.code == "account_unavailable" || isAccountUnavailableMessage(payload.message) + case .server(let message): + return isAccountUnavailableMessage(message) + default: + return false + } } private func isAccountUnavailableMessage(_ message: String) -> Bool { @@ -4072,6 +4344,8 @@ final class CodexPhoneModel: ObservableObject { switch gatewayError { case .http(let status): return status == 404 + case .gateway(let payload): + return payload.code == "job_not_found" case .server(let message): return message == "Job not found" default: @@ -4397,6 +4671,20 @@ struct CodexGatewayClient { return destination } + func startLocalWebSession(url: URL, baseURL: String, token: String) async throws -> URL { + let response: LocalWebSessionResponse = try await request( + "/api/local-web/sessions", + method: "POST", + body: LocalWebSessionRequest(url: url.absoluteString), + baseURL: baseURL, + token: token + ) + guard let sessionURL = localWebSessionURL(path: response.path, baseURL: baseURL) else { + throw GatewayError.invalidResponse + } + return sessionURL + } + private func get(_ path: String, baseURL: String, token: String) async throws -> T { try await request(path, method: "GET", body: Optional<[String: String]>.none, baseURL: baseURL, token: token) } @@ -4431,6 +4719,9 @@ struct CodexGatewayClient { throw GatewayError.invalidResponse } guard (200..<300).contains(http.statusCode) else { + if let payload = try? JSONDecoder().decode(GatewayErrorPayload.self, from: data) { + throw GatewayError.gateway(payload.error) + } if let envelope = try? JSONDecoder().decode(ErrorEnvelope.self, from: data) { throw GatewayError.server(envelope.error) } @@ -4657,6 +4948,17 @@ struct PinThreadRequest: Encodable { let pinned: Bool } +struct LocalWebSessionRequest: Encodable { + let url: String +} + +struct LocalWebSessionResponse: Decodable { + let sessionId: String + let path: String + let targetOrigin: String + let expiresAt: Int +} + struct QueuedPrompt: Codable, Identifiable, Hashable { let id: UUID let threadID: String @@ -4778,6 +5080,38 @@ struct ErrorEnvelope: Decodable { let error: String } +struct GatewayErrorPayload: Decodable, Equatable { + struct ErrorBody: Decodable, Equatable { + let code: String + let message: String + let recovery: String + } + + let error: ErrorBody +} + +enum GatewayErrorPresenter { + static func title(for error: GatewayErrorPayload.ErrorBody) -> String { + error.message + } + + static func recovery(for error: GatewayErrorPayload.ErrorBody) -> String { + error.recovery + } + + static func message(for error: Error) -> String { + if let gatewayError = error as? GatewayError { + switch gatewayError { + case .gateway(let payload): + return recovery(for: payload) + default: + return gatewayError.localizedDescription + } + } + return error.localizedDescription + } +} + struct CodexThread: Decodable, Identifiable, Hashable { let id: String let title: String @@ -5192,6 +5526,7 @@ enum GatewayError: LocalizedError { case invalidResponse case http(Int) case server(String) + case gateway(GatewayErrorPayload.ErrorBody) var errorDescription: String? { switch self { @@ -5203,6 +5538,8 @@ enum GatewayError: LocalizedError { "Gateway returned HTTP \(status)" case .server(let message): message + case .gateway(let payload): + GatewayErrorPresenter.recovery(for: payload) } } } diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteControlBar.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteControlBar.swift new file mode 100644 index 00000000..bd0d3c8f --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteControlBar.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct RemoteControlBar: View { + let latencyText: String + let isPanMode: Bool + let onKeyboard: () -> Void + let onClipboard: () -> Void + let onMode: () -> Void + let onDisconnect: () -> Void + + var body: some View { + HStack(spacing: 18) { + Button(action: onKeyboard) { + Image(systemName: "keyboard") + } + Button(action: onClipboard) { + Image(systemName: "doc.on.clipboard") + } + Button(action: onMode) { + Image(systemName: isPanMode ? "hand.draw.fill" : "cursorarrow.motionlines") + } + .tint(isPanMode ? .accentColor : nil) + Text(latencyText) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(minWidth: 58) + Button(role: .destructive, action: onDisconnect) { + Image(systemName: "xmark.circle.fill") + } + } + .buttonStyle(.bordered) + .controlSize(.large) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + } +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift new file mode 100644 index 00000000..80414194 --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift @@ -0,0 +1,231 @@ +import Foundation + +enum RemoteDesktopAPIError: Error, Equatable { + case invalidURL + case invalidResponse + case server(status: Int, code: String) +} + +struct RemoteDesktopAPI { + typealias Transport = (URLRequest) async throws -> (Data, HTTPURLResponse) + + let baseURL: URL + let token: String + let transport: Transport + + init( + baseURL: URL, + token: String, + transport: @escaping Transport = RemoteDesktopAPI.defaultTransport + ) { + self.baseURL = baseURL + self.token = token + self.transport = transport + } + + func startPairing(deviceID: String, name: String, publicKey: Data) async throws -> RemotePairingChallenge { + try await request( + "POST", + path: "/api/remote/pairing/start", + body: [ + "deviceId": deviceID, + "name": name, + "publicKey": publicKey.base64EncodedString() + ] + ) + } + + func completePairing(challengeID: String, deviceID: String, signature: Data) async throws -> TrustedRemoteDevice { + try await request( + "POST", + path: "/api/remote/pairing/complete", + body: [ + "challengeId": challengeID, + "deviceId": deviceID, + "signature": signature.base64EncodedString() + ] + ) + } + + func startSession(deviceID: String, nonce: String, signature: Data) async throws -> RemoteDesktopLease { + try await request( + "POST", + path: "/api/remote/sessions", + body: [ + "deviceId": deviceID, + "nonce": nonce, + "signature": signature.base64EncodedString() + ] + ) + } + + func status() async throws -> RemoteDesktopHostStatus { + try await request("GET", path: "/api/remote/status", body: nil) + } + + func sendSignal( + sessionID: String, + sequence: UInt64, + kind: RemotePeerSignal.Kind, + payload: Data + ) async throws -> [RemotePeerSignal] { + let response: RemoteSignalAcknowledgement = try await request( + "POST", + path: "/api/remote/sessions/\(sessionID)/signal", + body: [ + "sequence": sequence, + "kind": kind.rawValue, + "payload": payload.base64EncodedString() + ] + ) + return response.signals ?? [] + } + + func frame() async throws -> Data { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + throw RemoteDesktopAPIError.invalidURL + } + let basePath = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + components.path = "/" + ([basePath, "api", "remote", "frame"].filter { !$0.isEmpty }.joined(separator: "/")) + components.queryItems = [URLQueryItem(name: "t", value: String(Date().timeIntervalSince1970))] + guard let url = components.url else { + throw RemoteDesktopAPIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 5 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("image/jpeg", forHTTPHeaderField: "Accept") + + let (data, response) = try await transport(request) + guard (200..<300).contains(response.statusCode) else { + let code = (try? JSONDecoder().decode(RemoteDesktopAPIErrorResponse.self, from: data).error) ?? "remote_desktop_error" + throw RemoteDesktopAPIError.server(status: response.statusCode, code: code) + } + return data + } + + func sendInput(_ event: RemoteInputEvent) async throws -> RemoteInputAcknowledgement { + guard let url = URL(string: "/api/remote/input", relativeTo: baseURL)?.absoluteURL else { + throw RemoteDesktopAPIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 5 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(event) + let (data, response) = try await transport(request) + guard (200..<300).contains(response.statusCode) else { + let code = (try? JSONDecoder().decode(RemoteDesktopAPIErrorResponse.self, from: data).error) ?? "remote_desktop_error" + throw RemoteDesktopAPIError.server(status: response.statusCode, code: code) + } + return try JSONDecoder().decode(RemoteInputAcknowledgement.self, from: data) + } + + private func request(_ method: String, path: String, body: [String: Any]?) async throws -> T { + guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { + throw RemoteDesktopAPIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 30 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + + let (data, response) = try await transport(request) + guard (200..<300).contains(response.statusCode) else { + let code = (try? JSONDecoder().decode(RemoteDesktopAPIErrorResponse.self, from: data).error) ?? "remote_desktop_error" + throw RemoteDesktopAPIError.server(status: response.statusCode, code: code) + } + do { + return try JSONDecoder.remoteDesktop.decode(T.self, from: data) + } catch { + throw RemoteDesktopAPIError.invalidResponse + } + } + + private static func defaultTransport(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw RemoteDesktopAPIError.invalidResponse + } + return (data, http) + } +} + +struct RemoteInputAcknowledgement: Decodable, Equatable { + struct Cursor: Decodable, Equatable { + let x: Double + let y: Double + } + + let ok: Bool + let cursor: Cursor? +} + +struct RemotePeerSignal: Codable, Equatable { + enum Kind: String, Codable { + case offer + case answer + case ice + } + + let leaseID: String + let sequence: UInt64 + let kind: Kind + let payload: Data +} + +private struct RemoteSignalAcknowledgement: Decodable { + let sequence: UInt64 + let signals: [RemotePeerSignal]? +} + +struct RemoteDesktopHostStatus: Codable, Equatable { + let ok: Bool? + let screenRecordingGranted: Bool? + let accessibilityGranted: Bool? + let macUnlocked: Bool? + let displayFrame: RemoteDisplayFrame? + let cursor: RemoteCursorPosition? + let iceServers: [RemoteIceServer]? + let capabilities: RemoteDesktopCapabilities? +} + +struct RemoteDisplayFrame: Codable, Equatable { + let width: Double + let height: Double +} + +struct RemoteCursorPosition: Codable, Equatable { + let x: Double + let y: Double +} + +struct RemoteDesktopCapabilities: Codable, Equatable { + let relayAvailable: Bool? +} + +struct RemoteIceServer: Codable, Equatable { + let urls: [String] + let username: String? + let credential: String? +} + +private struct RemoteDesktopAPIErrorResponse: Decodable { + let error: String +} + +private extension JSONDecoder { + static var remoteDesktop: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.dataDecodingStrategy = .base64 + return decoder + } +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift new file mode 100644 index 00000000..85200438 --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift @@ -0,0 +1,89 @@ +import Foundation + +enum RemoteInputKind: String, Codable { + case pointer + case buttonDown + case buttonUp + case scroll + case keyDown + case keyUp + case text +} + +struct RemoteInputEvent: Codable, Equatable { + let sessionId: String + let sequence: UInt64 + let kind: RemoteInputKind + let x: Double? + let y: Double? + let button: Int? + let keyCode: UInt16? + let text: String? + let deltaX: Double? + let deltaY: Double? +} + +struct RemoteDisplay: Codable, Equatable, Identifiable { + let id: UInt32 + let name: String + let pixelWidth: Int + let pixelHeight: Int + let scale: Double + let rotation: Double +} + +struct RemotePairingChallenge: Codable, Equatable, Identifiable { + let id: String + let code: String + let macName: String + let expiresAt: Date +} + +struct TrustedRemoteDevice: Codable, Equatable, Identifiable { + let id: String + var name: String + let publicKeyRawRepresentation: Data + let approvedAt: Date + var revokedAt: Date? +} + +struct RemoteDesktopLease: Codable, Equatable, Identifiable { + let id: String + let deviceId: String + let expiresAt: Date +} + +struct RemoteSessionDescription: Codable, Equatable { + let type: String + let sdp: String +} + +struct RemoteICECandidate: Codable, Equatable { + let candidate: String + let sdpMid: String? + let sdpMLineIndex: Int32? +} + +enum RemoteClipboardDirection: String, Codable { + case send + case receive +} + +struct RemoteClipboardRequest: Codable, Equatable { + let sessionId: String + let direction: RemoteClipboardDirection + let text: String? +} + +struct RemoteAuditEvent: Codable, Equatable, Identifiable { + let id: String + let timestamp: Date + let kind: String + let deviceId: String? + let sessionId: String? +} + +struct RemoteAuditEventResponse: Codable, Equatable { + let events: [RemoteAuditEvent] + let nextCursor: String? +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopSession.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopSession.swift new file mode 100644 index 00000000..7546cef4 --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopSession.swift @@ -0,0 +1,75 @@ +import Foundation + +enum RemoteDesktopSessionPhase: Equatable { + case connecting + case connected + case reconnecting + case suspended + case failed(String) + case disconnected +} + +enum RemoteDesktopTransportPath: Equatable { + case unknown + case direct + case relayed +} + +struct RemoteDesktopSessionState: Equatable { + let leaseID: String + private(set) var phase: RemoteDesktopSessionPhase + private(set) var transportPath: RemoteDesktopTransportPath + private(set) var pendingInputs: [RemoteInputEvent] + private var backgroundedAt: Date? + private let backgroundGrace: TimeInterval + + init(leaseID: String, now: Date = Date(), backgroundGrace: TimeInterval = 30) { + self.leaseID = leaseID + self.phase = .connecting + self.transportPath = .unknown + self.pendingInputs = [] + self.backgroundedAt = nil + self.backgroundGrace = backgroundGrace + } + + mutating func connected(now: Date = Date()) { + phase = .connected + backgroundedAt = nil + } + + mutating func enterBackground(now: Date = Date()) { + phase = .suspended + backgroundedAt = now + pendingInputs.removeAll() + } + + mutating func enterForeground(now: Date = Date()) { + guard !shouldRequireNewLease(now: now) else { + phase = .disconnected + pendingInputs.removeAll() + return + } + phase = .reconnecting + } + + func shouldRequireNewLease(now: Date = Date()) -> Bool { + guard let backgroundedAt else { return false } + return now.timeIntervalSince(backgroundedAt) > backgroundGrace + } + + mutating func enqueueInput(_ event: RemoteInputEvent) { + guard phase == .connected else { return } + pendingInputs.append(event) + } + + mutating func updateTransportPath(_ path: RemoteDesktopTransportPath) { + transportPath = path + } + + mutating func disconnect() { + phase = .disconnected + transportPath = .unknown + pendingInputs.removeAll() + backgroundedAt = nil + } +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift new file mode 100644 index 00000000..9b9f0a76 --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift @@ -0,0 +1,425 @@ +import SwiftUI +import UIKit + +struct RemoteDesktopView: View { + private enum InteractionMode { + case control + case pan + } + + @Environment(\.dismiss) private var dismiss + @FocusState private var keyboardFocused: Bool + @StateObject private var peer = RemotePeerConnection() + @AppStorage("gatewayURL") private var gatewayURL = "" + @AppStorage("gatewayToken") private var gatewayToken = "" + @State private var session = RemoteDesktopSessionState(leaseID: "preview") + @State private var mapper = RemoteInputMapper(sessionID: "preview") + @State private var frameImage: UIImage? + @State private var frameError: String? + @State private var permissionWarning: String? + @State private var frameTask: Task? + @State private var webRTCTask: Task? + @State private var inputTask: Task? + @State private var interactionMode: InteractionMode = .control + @State private var zoomScale: CGFloat = 1 + @State private var gestureZoomScale: CGFloat = 1 + @State private var viewport = RemoteViewport() + @State private var cursorCoordinateSize: CGSize? + @State private var keyboardText = "" + @State private var lastDragTranslation: CGSize = .zero + @State private var pendingPointerDelta: CGSize = .zero + @State private var lastPointerSendAt = Date.distantPast + + var body: some View { + GeometryReader { proxy in + ZStack { + Rectangle() + .fill(Color(.secondarySystemBackground)) + .overlay { + if let videoTrack = peer.videoTrack { + RemoteVideoView(track: videoTrack) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .scaleEffect(effectiveZoom) + .offset(viewportOffset(container: proxy.size)) + .background(Color.black) + } else if let frameImage { + Image(uiImage: frameImage) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .scaleEffect(effectiveZoom) + .offset(viewportOffset(container: proxy.size)) + .background(Color.black) + } else { + VStack(spacing: 10) { + Image(systemName: "desktopcomputer") + .font(.system(size: 54, weight: .semibold)) + Text(title) + .font(.headline) + Text(frameError ?? "Waiting for video from the Mac.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let delta = CGSize( + width: value.translation.width - lastDragTranslation.width, + height: value.translation.height - lastDragTranslation.height + ) + lastDragTranslation = value.translation + let remoteDelta = remotePointerDelta(forScreenDelta: delta, container: proxy.size) + pendingPointerDelta.width += remoteDelta.width + pendingPointerDelta.height += remoteDelta.height + predictCursor(remoteDelta: remoteDelta) + flushPointerDeltaIfNeeded() + } + .onEnded { value in + flushPointerDelta(force: true) + lastDragTranslation = .zero + if value.translation == .zero { + send(mapper.buttonDown(atNormalizedCursor: viewport.cursor)) + send(mapper.buttonUp(atNormalizedCursor: viewport.cursor)) + } + } + ) + .simultaneousGesture( + MagnificationGesture() + .onChanged { gestureZoomScale = $0 } + .onEnded { value in + zoomScale = min(4, max(1, zoomScale * value)) + viewport.zoom = zoomScale + gestureZoomScale = 1 + } + ) + .onTapGesture(count: 2) { + withAnimation(.easeOut(duration: 0.18)) { + zoomScale = zoomScale > 1 ? 1 : 2 + viewport.zoom = zoomScale + } + } + + if let imageSize = remoteImageSize { + Circle() + .strokeBorder(.black.opacity(0.75), lineWidth: 3) + .background(Circle().fill(.white.opacity(0.92))) + .frame(width: 11, height: 11) + .overlay { + Circle() + .fill(.cyan) + .frame(width: 4, height: 4) + } + .shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1) + .position(cursorPosition(container: proxy.size, image: imageSize)) + .allowsHitTesting(false) + } + + VStack { + if let permissionWarning { + Label(permissionWarning, systemImage: "exclamationmark.triangle.fill") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.orange, in: RoundedRectangle(cornerRadius: 7)) + .padding(.top, 12) + } + Spacer() + RemoteControlBar( + latencyText: peer.latencyText, + isPanMode: interactionMode == .pan, + onKeyboard: { keyboardFocused = true }, + onClipboard: { pasteClipboard() }, + onMode: { + interactionMode = interactionMode == .control ? .pan : .control + }, + onDisconnect: { + session.disconnect() + peer.disconnect() + dismiss() + } + ) + .padding(.bottom, 22) + } + + TextField("", text: $keyboardText) + .focused($keyboardFocused) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .frame(width: 1, height: 1) + .opacity(0.01) + .onChange(of: keyboardText) { oldValue, newValue in + streamKeyboardChange(from: oldValue, to: newValue) + } + } + .ignoresSafeArea(edges: .bottom) + .onAppear { + mapper = RemoteInputMapper(sessionID: UUID().uuidString) + peer.connect() + session.connected() + startFrameLoop() + startWebRTC() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in + session.enterBackground() + peer.suspend() + stopFrameLoop() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + session.enterForeground() + if !session.shouldRequireNewLease() { + peer.connect() + startFrameLoop() + startWebRTC() + } + } + .onReceive(peer.$videoTrack) { track in + if track != nil { + stopFrameLoop() + } + } + .onReceive(peer.$remoteCursor) { cursor in + guard let cursor else { return } + viewport.cursor = cursor + } + .onDisappear { + stopFrameLoop() + stopWebRTC() + inputTask?.cancel() + } + } + .navigationTitle("Remote Desktop") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Text("Typing live on Mac") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Done") { keyboardFocused = false } + } + } + } + + private func startFrameLoop() { + stopFrameLoop() + guard let baseURL = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + frameError = "Gateway is not configured." + return + } + let api = RemoteDesktopAPI(baseURL: baseURL, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) + frameTask = Task { + if let status = try? await api.status() { + await MainActor.run { + if let displayFrame = status.displayFrame, displayFrame.width > 0, displayFrame.height > 0 { + cursorCoordinateSize = CGSize(width: displayFrame.width, height: displayFrame.height) + } + if let cursor = status.cursor { + viewport.cursor = CGPoint(x: cursor.x, y: cursor.y) + } + if status.screenRecordingGranted == false { + permissionWarning = "Allow Screen Recording for CodePilot on the Mac." + } else if status.accessibilityGranted == false { + permissionWarning = "Allow Accessibility for CodePilot on the Mac to enable control." + } else { + permissionWarning = nil + } + } + } + while !Task.isCancelled { + do { + let data = try await api.frame() + if let image = UIImage(data: data) { + await MainActor.run { + frameImage = image + frameError = nil + session.connected() + } + } + try await Task.sleep(nanoseconds: 140_000_000) + } catch { + await MainActor.run { + frameError = "Video unavailable: \(Self.errorText(error))" + } + try? await Task.sleep(nanoseconds: 700_000_000) + } + } + } + } + + private func startWebRTC() { + stopWebRTC() + guard let baseURL = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let api = RemoteDesktopAPI(baseURL: baseURL, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) + webRTCTask = Task { + while !Task.isCancelled { + do { + try await peer.connect(api: api) + await MainActor.run { + frameError = nil + } + return + } catch { + await MainActor.run { + frameError = "WebRTC reconnecting: \(Self.errorText(error))" + } + try? await Task.sleep(nanoseconds: 1_250_000_000) + } + } + } + } + + private func stopWebRTC() { + webRTCTask?.cancel() + webRTCTask = nil + } + + private func stopFrameLoop() { + frameTask?.cancel() + frameTask = nil + } + + private func send(_ event: RemoteInputEvent) { + session.enqueueInput(event) + if peer.sendInput(event) { + return + } + guard let baseURL = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let api = RemoteDesktopAPI(baseURL: baseURL, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) + let previous = inputTask + inputTask = Task { + _ = await previous?.result + guard !Task.isCancelled else { return } + do { + let acknowledgement = try await api.sendInput(event) + if let cursor = acknowledgement.cursor { + await MainActor.run { + if event.kind != .pointer || event.deltaX == nil || event.deltaY == nil { + viewport.cursor = CGPoint(x: cursor.x, y: cursor.y) + } + } + } + } catch { + await MainActor.run { + frameError = "Control unavailable: \(Self.errorText(error))" + } + } + } + } + + private func pasteClipboard() { + guard let text = UIPasteboard.general.string, !text.isEmpty else { + frameError = "The iPhone clipboard has no text." + return + } + send(mapper.text(text)) + } + + private func flushPointerDeltaIfNeeded() { + guard Date().timeIntervalSince(lastPointerSendAt) >= pointerFlushInterval else { return } + flushPointerDelta(force: false) + } + + private func flushPointerDelta(force: Bool) { + guard force || Date().timeIntervalSince(lastPointerSendAt) >= pointerFlushInterval else { return } + guard pendingPointerDelta != .zero else { return } + let delta = pendingPointerDelta + pendingPointerDelta = .zero + lastPointerSendAt = Date() + send(mapper.moveRelative(delta: delta, sensitivity: 1)) + } + + private func streamKeyboardChange(from oldValue: String, to newValue: String) { + if newValue.hasPrefix(oldValue) { + let added = String(newValue.dropFirst(oldValue.count)) + if !added.isEmpty { send(mapper.text(added)) } + return + } + + let removedCount: Int + if oldValue.hasPrefix(newValue) { + removedCount = oldValue.count - newValue.count + } else { + removedCount = oldValue.count + } + for _ in 0.. CGSize { + guard let imageSize = remoteImageSize else { return .zero } + var current = viewport + current.zoom = effectiveZoom + return current.offset(container: container, image: imageSize) + } + + private func cursorPosition(container: CGSize, image: CGSize) -> CGPoint { + var current = viewport + current.zoom = effectiveZoom + return current.cursorMarkerPosition(container: container, image: image) + } + + private func predictCursor(remoteDelta: CGSize) { + guard let coordinateSize = cursorCoordinateSize ?? frameImage?.size else { return } + viewport.applyPointerDelta(remoteDelta, coordinateSize: coordinateSize, sensitivity: 1) + } + + private var remoteImageSize: CGSize? { + if let cursorCoordinateSize, cursorCoordinateSize.width > 0, cursorCoordinateSize.height > 0 { + return cursorCoordinateSize + } + return frameImage?.size + } + + private func remotePointerDelta(forScreenDelta delta: CGSize, container: CGSize) -> CGSize { + guard let imageSize = remoteImageSize else { return delta } + var current = viewport + current.zoom = effectiveZoom + return current.remoteDelta(forScreenDelta: delta, container: container, image: imageSize) + } + + private static func errorText(_ error: Error) -> String { + if case RemoteDesktopAPIError.server(_, let code) = error { + return code + } + return error.localizedDescription + } + + private var title: String { + switch session.phase { + case .connecting: + return "Connecting" + case .connected: + return "Connected" + case .reconnecting: + return "Reconnecting" + case .suspended: + return "Suspended" + case .failed(let message): + return message + case .disconnected: + return "Disconnected" + } + } +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDeviceIdentity.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDeviceIdentity.swift new file mode 100644 index 00000000..79a98b5f --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDeviceIdentity.swift @@ -0,0 +1,69 @@ +import CryptoKit +import Foundation +import LocalAuthentication +import Security +import UIKit + +protocol RemoteDeviceIdentity { + var deviceID: String { get } + var publicKeyRawRepresentation: Data { get } + func sign(_ message: Data) throws -> Data +} + +struct SoftwareRemoteDeviceIdentity: RemoteDeviceIdentity { + let deviceID: String + private let privateKey: P256.Signing.PrivateKey + + init(deviceID: String, privateKey: P256.Signing.PrivateKey = P256.Signing.PrivateKey()) { + self.deviceID = deviceID + self.privateKey = privateKey + } + + var publicKeyRawRepresentation: Data { + privateKey.publicKey.rawRepresentation + } + + func sign(_ message: Data) throws -> Data { + try privateKey.signature(for: message).derRepresentation + } + + func verify(signature: Data, message: Data) throws -> Bool { + let parsed = try P256.Signing.ECDSASignature(derRepresentation: signature) + return privateKey.publicKey.isValidSignature(parsed, for: message) + } +} + +final class SecureEnclaveRemoteDeviceIdentity: RemoteDeviceIdentity { + let deviceID: String + private let privateKey: SecureEnclave.P256.Signing.PrivateKey + + init(deviceID: String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString) throws { + guard SecureEnclave.isAvailable else { + throw RemoteDeviceIdentityError.secureEnclaveUnavailable + } + let access = SecAccessControlCreateWithFlags( + nil, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + [.privateKeyUsage, .userPresence], + nil + ) + guard let access else { + throw RemoteDeviceIdentityError.secureEnclaveUnavailable + } + self.deviceID = deviceID + self.privateKey = try SecureEnclave.P256.Signing.PrivateKey(accessControl: access) + } + + var publicKeyRawRepresentation: Data { + privateKey.publicKey.rawRepresentation + } + + func sign(_ message: Data) throws -> Data { + try privateKey.signature(for: message).derRepresentation + } +} + +enum RemoteDeviceIdentityError: Error, Equatable { + case secureEnclaveUnavailable + case authenticationCancelled +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteInputMapper.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteInputMapper.swift new file mode 100644 index 00000000..1a01a11d --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteInputMapper.swift @@ -0,0 +1,115 @@ +import CoreGraphics +import Foundation + +struct RemoteInputMapper { + private(set) var sessionID: String + private var nextSequence: UInt64 = 1 + + init(sessionID: String) { + self.sessionID = sessionID + } + + mutating func tap(at point: CGPoint, in size: CGSize) -> RemoteInputEvent { + pointer(kind: .pointer, point: point, size: size) + } + + mutating func drag(to point: CGPoint, in size: CGSize) -> RemoteInputEvent { + pointer(kind: .pointer, point: point, size: size) + } + + mutating func moveRelative(delta: CGSize, sensitivity: CGFloat = 1.35) -> RemoteInputEvent { + make( + kind: .pointer, + deltaX: Double(delta.width * sensitivity), + deltaY: Double(delta.height * sensitivity) + ) + } + + mutating func buttonDown(at point: CGPoint, in size: CGSize, button: Int = 0) -> RemoteInputEvent { + pointer(kind: .buttonDown, point: point, size: size, button: button) + } + + mutating func buttonUp(at point: CGPoint, in size: CGSize, button: Int = 0) -> RemoteInputEvent { + pointer(kind: .buttonUp, point: point, size: size, button: button) + } + + mutating func buttonDown(button: Int = 0) -> RemoteInputEvent { + make(kind: .buttonDown, button: button) + } + + mutating func buttonUp(button: Int = 0) -> RemoteInputEvent { + make(kind: .buttonUp, button: button) + } + + mutating func buttonDown(atNormalizedCursor cursor: CGPoint, button: Int = 0) -> RemoteInputEvent { + make(kind: .buttonDown, x: normalizedUnit(cursor.x), y: normalizedUnit(cursor.y), button: button) + } + + mutating func buttonUp(atNormalizedCursor cursor: CGPoint, button: Int = 0) -> RemoteInputEvent { + make(kind: .buttonUp, x: normalizedUnit(cursor.x), y: normalizedUnit(cursor.y), button: button) + } + + mutating func scroll(delta: CGSize) -> RemoteInputEvent { + make(kind: .scroll, deltaX: delta.width, deltaY: delta.height) + } + + mutating func keyDown(_ keyCode: UInt16) -> RemoteInputEvent { + make(kind: .keyDown, keyCode: keyCode) + } + + mutating func keyUp(_ keyCode: UInt16) -> RemoteInputEvent { + make(kind: .keyUp, keyCode: keyCode) + } + + mutating func text(_ text: String) -> RemoteInputEvent { + make(kind: .text, text: text) + } + + private mutating func pointer( + kind: RemoteInputKind, + point: CGPoint, + size: CGSize, + button: Int? = nil + ) -> RemoteInputEvent { + make( + kind: kind, + x: normalized(point.x, size.width), + y: normalized(point.y, size.height), + button: button + ) + } + + private mutating func make( + kind: RemoteInputKind, + x: Double? = nil, + y: Double? = nil, + button: Int? = nil, + keyCode: UInt16? = nil, + text: String? = nil, + deltaX: Double? = nil, + deltaY: Double? = nil + ) -> RemoteInputEvent { + defer { nextSequence += 1 } + return RemoteInputEvent( + sessionId: sessionID, + sequence: nextSequence, + kind: kind, + x: x, + y: y, + button: button, + keyCode: keyCode, + text: text, + deltaX: deltaX, + deltaY: deltaY + ) + } + + private func normalized(_ value: CGFloat, _ dimension: CGFloat) -> Double { + guard dimension > 0 else { return 0 } + return min(1, max(0, Double(value / dimension))) + } + + private func normalizedUnit(_ value: CGFloat) -> Double { + min(1, max(0, Double(value))) + } +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift new file mode 100644 index 00000000..c7087db1 --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift @@ -0,0 +1,122 @@ +import SwiftUI +import UIKit + +struct RemotePairingView: View { + @Environment(\.dismiss) private var dismiss + @AppStorage("gatewayURL") private var gatewayURL = "" + @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") { + LabeledContent("Device ID", value: identity.deviceID) + LabeledContent("Public key", value: identity.publicKeyRawRepresentation.base64EncodedString()) + .textSelection(.enabled) + } + + Section { + NavigationLink { + RemoteDesktopView() + } label: { + Label("Start Session", systemImage: "play.rectangle") + } + .disabled(statusText == "Not paired") + + Button { + Task { await refreshStatus() } + } label: { + Label("Refresh Status", systemImage: "arrow.clockwise") + } + .disabled(isLoading) + + Button { + Task { await startPairing() } + } label: { + Label("Start Pairing", systemImage: "desktopcomputer") + } + .disabled(isLoading || gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .navigationTitle("Remote Desktop") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Label("Back", systemImage: "chevron.left") + } + } + } + .task { + await refreshStatus() + } + } + } + + private func refreshStatus() async { + guard let api = api() else { return } + isLoading = true + defer { isLoading = false } + do { + let status = try await api.status() + if status.screenRecordingGranted == false || status.accessibilityGranted == false { + let missing = [ + status.screenRecordingGranted == false ? "Screen Recording" : nil, + status.accessibilityGranted == false ? "Accessibility" : nil + ].compactMap { $0 }.joined(separator: " and ") + statusText = "Allow \(missing) for CodePilot on the Mac" + } else if status.capabilities?.relayAvailable == true { + statusText = "Host reachable, relay available" + } else { + statusText = "Host reachable, local/STUN only" + } + } catch { + statusText = "Host unavailable" + } + } + + private func startPairing() async { + guard let api = api() else { return } + isLoading = true + defer { isLoading = false } + do { + let challenge = try await api.startPairing( + deviceID: identity.deviceID, + name: UIDevice.current.name, + publicKey: identity.publicKeyRawRepresentation + ) + let signature = try identity.sign(Data(challenge.code.utf8)) + _ = try await api.completePairing( + challengeID: challenge.id, + deviceID: identity.deviceID, + signature: signature + ) + statusText = "Paired with \(challenge.macName)" + } catch RemoteDesktopAPIError.server(_, let code) { + statusText = code + } catch { + statusText = "Pairing failed" + } + } + + private func api() -> RemoteDesktopAPI? { + guard let url = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return RemoteDesktopAPI(baseURL: url, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift new file mode 100644 index 00000000..76701945 --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift @@ -0,0 +1,198 @@ +import CoreGraphics +import Foundation +import WebRTC + +enum RemotePeerError: Error { + case peerCreationFailed + case offerCreationFailed + case missingLocalDescription + case missingAnswer +} + +final class RemotePeerConnection: NSObject, ObservableObject, RTCPeerConnectionDelegate, RTCDataChannelDelegate { + @Published private(set) var isConnected = false + @Published private(set) var isInputReady = false + @Published private(set) var latencyText = "--" + @Published private(set) var videoTrack: RTCVideoTrack? + @Published private(set) var remoteCursor: CGPoint? + + private let factory = RTCPeerConnectionFactory() + private var peerConnection: RTCPeerConnection? + private var inputDataChannel: RTCDataChannel? + private var sessionID = "" + + @MainActor + func connect(api: RemoteDesktopAPI) async throws { + disconnect() + sessionID = UUID().uuidString + latencyText = "connecting" + + let status = try await api.status() + let configuration = RTCConfiguration() + configuration.sdpSemantics = .unifiedPlan + configuration.continualGatheringPolicy = .gatherContinually + configuration.iceServers = (status.iceServers ?? []).map { server in + RTCIceServer( + urlStrings: server.urls, + username: server.username, + credential: server.credential + ) + } + let constraints = RTCMediaConstraints( + mandatoryConstraints: [kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue], + optionalConstraints: nil + ) + guard let peer = factory.peerConnection( + with: configuration, + constraints: constraints, + delegate: self + ) else { + throw RemotePeerError.peerCreationFailed + } + peerConnection = peer + let dataConfiguration = RTCDataChannelConfiguration() + dataConfiguration.isOrdered = true + if let dataChannel = peer.dataChannel(forLabel: "codepilot-input", configuration: dataConfiguration) { + dataChannel.delegate = self + inputDataChannel = dataChannel + } + + let offer = try await createOffer(on: peer, constraints: constraints) + try await setLocalDescription(offer, on: peer) + try await Task.sleep(nanoseconds: 750_000_000) + guard let local = peer.localDescription else { throw RemotePeerError.missingLocalDescription } + let signals = try await api.sendSignal( + sessionID: sessionID, + sequence: 1, + kind: .offer, + payload: Data(local.sdp.utf8) + ) + guard let answerSignal = signals.first(where: { $0.kind == .answer }), + let answerSDP = String(data: answerSignal.payload, encoding: .utf8) else { + throw RemotePeerError.missingAnswer + } + try await setRemoteDescription( + RTCSessionDescription(type: .answer, sdp: answerSDP), + on: peer + ) + } + + func connect() { + latencyText = "connecting" + } + + func suspend() { + isConnected = false + isInputReady = false + latencyText = "suspended" + } + + func disconnect() { + inputDataChannel?.delegate = nil + inputDataChannel?.close() + inputDataChannel = nil + peerConnection?.close() + peerConnection = nil + videoTrack = nil + remoteCursor = nil + isConnected = false + isInputReady = false + latencyText = "--" + } + + func sendInput(_ event: RemoteInputEvent) -> Bool { + guard let inputDataChannel, inputDataChannel.readyState == .open else { + return false + } + do { + let data = try JSONEncoder().encode(event) + return inputDataChannel.sendData(RTCDataBuffer(data: data, isBinary: false)) + } catch { + return false + } + } + + private func createOffer( + on peer: RTCPeerConnection, + constraints: RTCMediaConstraints + ) async throws -> RTCSessionDescription { + try await withCheckedThrowingContinuation { continuation in + peer.offer(for: constraints) { description, error in + if let error { continuation.resume(throwing: error) } + else if let description { continuation.resume(returning: description) } + else { continuation.resume(throwing: RemotePeerError.offerCreationFailed) } + } + } + } + + private func setLocalDescription(_ description: RTCSessionDescription, on peer: RTCPeerConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + peer.setLocalDescription(description) { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + } + } + } + + private func setRemoteDescription(_ description: RTCSessionDescription, on peer: RTCPeerConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + peer.setRemoteDescription(description) { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + } + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {} + func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} + + func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { + DispatchQueue.main.async { + self.isInputReady = dataChannel.readyState == .open + } + } + + func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) { + guard !buffer.isBinary, + let update = try? JSONDecoder().decode(RemotePeerCursorUpdate.self, from: buffer.data) else { + return + } + DispatchQueue.main.async { + self.remoteCursor = CGPoint(x: update.cursor.x, y: update.cursor.y) + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { + DispatchQueue.main.async { + self.isConnected = newState == .connected + self.latencyText = newState == .connected ? "WebRTC" : String(describing: newState) + } + } + + func peerConnection( + _ peerConnection: RTCPeerConnection, + didAdd rtpReceiver: RTCRtpReceiver, + streams: [RTCMediaStream] + ) { + guard let track = rtpReceiver.track as? RTCVideoTrack else { return } + DispatchQueue.main.async { + self.videoTrack = track + } + } +} + +struct RemotePeerCursorUpdate: Codable, Equatable { + struct Cursor: Codable, Equatable { + let x: Double + let y: Double + } + + let cursor: Cursor +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteVideoView.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteVideoView.swift new file mode 100644 index 00000000..61835de2 --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteVideoView.swift @@ -0,0 +1,19 @@ +import SwiftUI +import WebRTC + +struct RemoteVideoView: UIViewRepresentable { + let track: RTCVideoTrack + + func makeUIView(context: Context) -> RTCMTLVideoView { + let view = RTCMTLVideoView(frame: .zero) + view.videoContentMode = .scaleAspectFit + track.add(view) + return view + } + + func updateUIView(_ uiView: RTCMTLVideoView, context: Context) {} + + static func dismantleUIView(_ uiView: RTCMTLVideoView, coordinator: Void) { + uiView.renderFrame(nil) + } +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteViewport.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteViewport.swift new file mode 100644 index 00000000..e875f721 --- /dev/null +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteViewport.swift @@ -0,0 +1,77 @@ +import CoreGraphics + +struct RemoteViewport: Equatable { + static let cursorMarkerHotspotYOffset: CGFloat = 15 + + var zoom: CGFloat + var cursor: CGPoint + + init(zoom: CGFloat = 1, cursor: CGPoint = CGPoint(x: 0.5, y: 0.5)) { + self.zoom = min(4, max(1, zoom)) + self.cursor = CGPoint( + x: min(1, max(0, cursor.x)), + y: min(1, max(0, cursor.y)) + ) + } + + func offset(container: CGSize, image: CGSize) -> CGSize { + let scaled = scaledImageSize(container: container, image: image) + let desired = CGSize( + width: scaled.width * (0.5 - cursor.x), + height: scaled.height * (0.5 - cursor.y) + ) + let horizontalLimit = max(0, (scaled.width - container.width) / 2) + let verticalLimit = max(0, (scaled.height - container.height) / 2) + return CGSize( + width: min(horizontalLimit, max(-horizontalLimit, desired.width)), + height: min(verticalLimit, max(-verticalLimit, desired.height)) + ) + } + + func cursorPosition(container: CGSize, image: CGSize) -> CGPoint { + let scaled = scaledImageSize(container: container, image: image) + let origin = CGPoint( + x: (container.width - scaled.width) / 2, + y: (container.height - scaled.height) / 2 + ) + let viewportOffset = offset(container: container, image: image) + return CGPoint( + x: origin.x + viewportOffset.width + cursor.x * scaled.width, + y: origin.y + viewportOffset.height + cursor.y * scaled.height + ) + } + + func cursorMarkerPosition(container: CGSize, image: CGSize) -> CGPoint { + let rawPosition = cursorPosition(container: container, image: image) + return CGPoint(x: rawPosition.x, y: rawPosition.y + Self.cursorMarkerHotspotYOffset) + } + + func remoteDelta(forScreenDelta delta: CGSize, container: CGSize, image: CGSize) -> CGSize { + let scaled = scaledImageSize(container: container, image: image) + guard scaled.width > 0, scaled.height > 0 else { return .zero } + return CGSize( + width: delta.width * image.width / scaled.width, + height: delta.height * image.height / scaled.height + ) + } + + mutating func applyPointerDelta( + _ delta: CGSize, + coordinateSize: CGSize, + sensitivity: CGFloat = 1.35 + ) { + guard coordinateSize.width > 0, coordinateSize.height > 0 else { return } + cursor = CGPoint( + x: min(1, max(0, cursor.x + delta.width * sensitivity / coordinateSize.width)), + y: min(1, max(0, cursor.y + delta.height * sensitivity / coordinateSize.height)) + ) + } + + private func scaledImageSize(container: CGSize, image: CGSize) -> CGSize { + guard container.width > 0, container.height > 0, image.width > 0, image.height > 0 else { + return .zero + } + let fitScale = min(container.width / image.width, container.height / image.height) + return CGSize(width: image.width * fitScale * zoom, height: image.height * fitScale * zoom) + } +} diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift new file mode 100644 index 00000000..060136d2 --- /dev/null +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -0,0 +1,454 @@ +import Foundation +import XCTest +@testable import CodexPhone + +final class RemoteDesktopTests: XCTestCase { + func testGatewayErrorRecoveryCopyUsesStablePayload() { + let error = GatewayErrorPayload.ErrorBody( + code: "gateway_unavailable", + message: "Gateway unavailable", + recovery: "Restart CodePilot Gateway on your Mac." + ) + + XCTAssertEqual(GatewayErrorPresenter.title(for: error), "Gateway unavailable") + XCTAssertEqual(GatewayErrorPresenter.recovery(for: error), "Restart CodePilot Gateway on your Mac.") + } + + func testGatewayConnectionKindUsesUserFacingTitles() { + XCTAssertEqual(GatewayConnectionKind.local.title, "Same Network") + XCTAssertEqual(GatewayConnectionKind.cloudflare.title, "Cloudflare") + XCTAssertTrue(GatewayConnectionKind.local.helpText.contains("same Wi-Fi")) + } + + func testMacLocalWebURLDetectionOnlyAcceptsLoopbackHTTPURLs() throws { + XCTAssertTrue(isMacLocalWebURL(try XCTUnwrap(URL(string: "http://localhost:3000")))) + XCTAssertTrue(isMacLocalWebURL(try XCTUnwrap(URL(string: "http://127.0.0.1:5173/path")))) + XCTAssertTrue(isMacLocalWebURL(try XCTUnwrap(URL(string: "https://localhost:8443")))) + XCTAssertFalse(isMacLocalWebURL(try XCTUnwrap(URL(string: "https://example.com")))) + XCTAssertFalse(isMacLocalWebURL(try XCTUnwrap(URL(string: "file:///tmp/app.log")))) + } + + func testLocalWebSessionURLResolvesGatewayRelativePath() throws { + let url = try XCTUnwrap(localWebSessionURL( + path: "/api/local-web/session-1/dashboard?tab=logs", + baseURL: "https://gateway.example/base" + )) + + XCTAssertEqual(url.absoluteString, "https://gateway.example/api/local-web/session-1/dashboard?tab=logs") + } + + func testRemoteFilePathAcceptsCustomPreviewURL() throws { + let url = try XCTUnwrap(remoteFilePreviewURL(path: "/Users/homeserver/Developer/CodePilot/README.md")) + + XCTAssertEqual(remoteFilePath(from: url), "/Users/homeserver/Developer/CodePilot/README.md") + } + + func testRemoteFilePathAcceptsMarkdownAbsolutePathURL() throws { + let url = try XCTUnwrap(URL(string: "/Users/homeserver/Developer/CodexAccountSwitcher/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md")) + + XCTAssertEqual( + remoteFilePath(from: url), + "/Users/homeserver/Developer/CodexAccountSwitcher/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md" + ) + } + + func testRemoteFilePathAcceptsFileURLAndStripsLineSuffix() throws { + let url = URL(fileURLWithPath: "/Users/homeserver/Developer/CodePilot/Sources/App.swift:42") + + XCTAssertEqual(remoteFilePath(from: url), "/Users/homeserver/Developer/CodePilot/Sources/App.swift") + } + + func testRemoteFilePathRejectsWebURLs() throws { + let url = try XCTUnwrap(URL(string: "https://example.com/Users/homeserver/README.md")) + + XCTAssertNil(remoteFilePath(from: url)) + } + + func testFilePreviewDismissesForIntentionalDownwardDrag() { + XCTAssertTrue(shouldDismissFilePreview( + translation: CGSize(width: 8, height: 96), + predictedEndTranslation: CGSize(width: 10, height: 110) + )) + XCTAssertTrue(shouldDismissFilePreview( + translation: CGSize(width: 12, height: 40), + predictedEndTranslation: CGSize(width: 14, height: 180) + )) + } + + func testFilePreviewKeepsOpenForSmallOrHorizontalDrag() { + XCTAssertFalse(shouldDismissFilePreview( + translation: CGSize(width: 6, height: 45), + predictedEndTranslation: CGSize(width: 7, height: 70) + )) + XCTAssertFalse(shouldDismissFilePreview( + translation: CGSize(width: 160, height: 100), + predictedEndTranslation: CGSize(width: 180, height: 170) + )) + } + + func testRenderedMessageMarkdownPreservesChatLineBreaks() { + let input = """ + Adjusted the Remote Desktop marker a little lower: offset is now 15pt instead of 11pt. + Verified: + - Simulator build passed. + - OTA build completed. + Commit: `42711c2` + """ + + let rendered = renderedMessageAttributedString(input) + + XCTAssertEqual( + String(rendered.characters), + """ + Adjusted the Remote Desktop marker a little lower: offset is now 15pt instead of 11pt. + Verified: + - Simulator build passed. + - OTA build completed. + Commit: 42711c2 + """ + ) + } + + func testCanonicalRemoteInputEventDecodes() throws { + let fixture = #"{"sessionId":"s1","sequence":4,"kind":"pointer","x":0.25,"y":0.75,"button":0,"keyCode":null,"text":null,"deltaX":null,"deltaY":null}"#.data(using: .utf8)! + + let event = try JSONDecoder().decode(RemoteInputEvent.self, from: fixture) + + XCTAssertEqual(event.sessionId, "s1") + XCTAssertEqual(event.sequence, 4) + XCTAssertEqual(event.kind, .pointer) + XCTAssertEqual(event.x, 0.25) + XCTAssertEqual(event.y, 0.75) + XCTAssertEqual(event.button, 0) + XCTAssertNil(event.keyCode) + XCTAssertNil(event.text) + XCTAssertNil(event.deltaX) + XCTAssertNil(event.deltaY) + } + + func testEveryWireModelRoundTrips() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let device = TrustedRemoteDevice( + id: "device-1", + name: "iPhone", + publicKeyRawRepresentation: Data([1, 2, 3, 4]), + approvedAt: date, + revokedAt: nil + ) + let auditEvent = RemoteAuditEvent( + id: "audit-1", + timestamp: date, + kind: "session.started", + deviceId: device.id, + sessionId: "session-1" + ) + + try assertRoundTrip(RemoteInputKind.keyDown) + try assertRoundTrip(RemoteInputEvent( + sessionId: "session-1", + sequence: 9, + kind: .scroll, + x: 0.25, + y: 0.75, + button: nil, + keyCode: nil, + text: nil, + deltaX: 1.5, + deltaY: -2.5 + )) + try assertRoundTrip(RemoteDisplay( + id: 42, + name: "Studio Display", + pixelWidth: 5120, + pixelHeight: 2880, + scale: 2, + rotation: 0 + )) + try assertRoundTrip(RemotePairingChallenge( + id: "challenge-1", + code: "123456", + macName: "Office Mac", + expiresAt: date + )) + try assertRoundTrip(device) + try assertRoundTrip(RemoteDesktopLease( + id: "lease-1", + deviceId: device.id, + expiresAt: date + )) + try assertRoundTrip(RemoteSessionDescription(type: "offer", sdp: "v=0")) + try assertRoundTrip(RemoteICECandidate( + candidate: "candidate:1 1 UDP 1 192.0.2.1 5000 typ host", + sdpMid: "0", + sdpMLineIndex: 0 + )) + try assertRoundTrip(RemoteClipboardDirection.send) + try assertRoundTrip(RemoteClipboardRequest( + sessionId: "session-1", + direction: .send, + text: "hello" + )) + try assertRoundTrip(auditEvent) + try assertRoundTrip(RemoteAuditEventResponse( + events: [auditEvent], + nextCursor: "cursor-2" + )) + } + + func testDateAndDataUseStableWireRepresentations() throws { + let device = TrustedRemoteDevice( + id: "device-1", + name: "iPhone", + publicKeyRawRepresentation: Data([1, 2, 3, 4]), + approvedAt: Date(timeIntervalSince1970: 1_700_000_000), + revokedAt: nil + ) + + let data = try wireEncoder().encode(device) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(json["approvedAt"] as? String, "2023-11-14T22:13:20Z") + XCTAssertEqual(json["publicKeyRawRepresentation"] as? String, "AQIDBA==") + XCTAssertEqual(try wireDecoder().decode(TrustedRemoteDevice.self, from: data), device) + } + + func testSoftwareIdentitySignsAndVerifiesFixture() throws { + let identity = SoftwareRemoteDeviceIdentity(deviceID: "device-1") + let message = Data("challenge-code".utf8) + let signature = try identity.sign(message) + + XCTAssertEqual(identity.deviceID, "device-1") + XCTAssertFalse(identity.publicKeyRawRepresentation.isEmpty) + XCTAssertTrue(try identity.verify(signature: signature, message: message)) + XCTAssertFalse(try identity.verify(signature: signature, message: Data("other".utf8))) + } + + func testRemoteDesktopAPIMapsRevokedDeviceAndPairingExpiry() async throws { + let revokedAPI = RemoteDesktopAPI( + baseURL: URL(string: "https://gateway.example")!, + token: "token" + ) { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer token") + let data = #"{"error":"untrusted_device"}"#.data(using: .utf8)! + return (data, HTTPURLResponse(url: request.url!, statusCode: 403, httpVersion: nil, headerFields: nil)!) + } + + do { + _ = try await revokedAPI.startSession(deviceID: "device-1", nonce: "nonce", signature: Data([1])) + XCTFail("Expected revoked device failure") + } catch RemoteDesktopAPIError.server(let status, let code) { + XCTAssertEqual(status, 403) + XCTAssertEqual(code, "untrusted_device") + } + + let expiredAPI = RemoteDesktopAPI( + baseURL: URL(string: "https://gateway.example")!, + token: "token" + ) { request in + let data = #"{"error":"pairing_expired"}"#.data(using: .utf8)! + return (data, HTTPURLResponse(url: request.url!, statusCode: 410, httpVersion: nil, headerFields: nil)!) + } + + do { + _ = try await expiredAPI.completePairing(challengeID: "challenge-1", deviceID: "device-1", signature: Data([1])) + XCTFail("Expected expired pairing failure") + } catch RemoteDesktopAPIError.server(let status, let code) { + XCTAssertEqual(status, 410) + XCTAssertEqual(code, "pairing_expired") + } + } + + func testRemoteDesktopAPISendsInputEvent() async throws { + let event = RemoteInputEvent( + sessionId: "gateway-session", + sequence: 3, + kind: .pointer, + x: 0.25, + y: 0.75, + button: nil, + keyCode: nil, + text: nil, + deltaX: nil, + deltaY: nil + ) + let api = RemoteDesktopAPI( + baseURL: URL(string: "https://gateway.example")!, + token: "token" + ) { request in + XCTAssertEqual(request.url?.path, "/api/remote/input") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer token") + XCTAssertEqual(try JSONDecoder().decode(RemoteInputEvent.self, from: request.httpBody!), event) + return ( + #"{"ok":true}"#.data(using: .utf8)!, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + + let acknowledgement = try await api.sendInput(event) + XCTAssertTrue(acknowledgement.ok) + } + + func testRemoteDesktopStatusDecodesDisplayFrameAndCursor() throws { + let data = #"{"ok":true,"displayFrame":{"width":2560,"height":1440},"cursor":{"x":0.25,"y":0.75}}"#.data(using: .utf8)! + + let status = try JSONDecoder().decode(RemoteDesktopHostStatus.self, from: data) + + XCTAssertEqual(status.displayFrame, RemoteDisplayFrame(width: 2560, height: 1440)) + XCTAssertEqual(status.cursor, RemoteCursorPosition(x: 0.25, y: 0.75)) + } + + func testRemotePeerCursorUpdateDecodes() throws { + let data = #"{"cursor":{"x":0.4,"y":0.6}}"#.data(using: .utf8)! + + let update = try JSONDecoder().decode(RemotePeerCursorUpdate.self, from: data) + + XCTAssertEqual(update, RemotePeerCursorUpdate(cursor: .init(x: 0.4, y: 0.6))) + } + + func testRemoteInputMapperCreatesMonotonicNormalizedEvents() { + var mapper = RemoteInputMapper(sessionID: "lease-1") + + let tap = mapper.tap(at: CGPoint(x: 50, y: 25), in: CGSize(width: 100, height: 100)) + let drag = mapper.drag(to: CGPoint(x: 25, y: 75), in: CGSize(width: 100, height: 100)) + let scroll = mapper.scroll(delta: CGSize(width: 3, height: -4)) + + XCTAssertEqual(tap.sequence, 1) + XCTAssertEqual(tap.kind, .pointer) + XCTAssertEqual(tap.x, 0.5) + XCTAssertEqual(tap.y, 0.25) + XCTAssertEqual(drag.sequence, 2) + XCTAssertEqual(drag.x, 0.25) + XCTAssertEqual(drag.y, 0.75) + XCTAssertEqual(scroll.sequence, 3) + XCTAssertEqual(scroll.kind, .scroll) + XCTAssertEqual(scroll.deltaX, 3) + XCTAssertEqual(scroll.deltaY, -4) + } + + func testRemoteInputMapperClicksAtNormalizedRemoteCursor() { + var mapper = RemoteInputMapper(sessionID: "lease-1") + + let down = mapper.buttonDown(atNormalizedCursor: CGPoint(x: 0.4, y: 0.6)) + let up = mapper.buttonUp(atNormalizedCursor: CGPoint(x: 0.4, y: 0.6)) + + XCTAssertEqual(down.sequence, 1) + XCTAssertEqual(down.kind, .buttonDown) + XCTAssertEqual(down.x, 0.4) + XCTAssertEqual(down.y, 0.6) + XCTAssertEqual(down.button, 0) + XCTAssertEqual(up.sequence, 2) + XCTAssertEqual(up.kind, .buttonUp) + XCTAssertEqual(up.x, 0.4) + XCTAssertEqual(up.y, 0.6) + XCTAssertEqual(up.button, 0) + } + + func testRemoteDesktopSessionBackgroundGraceAndDisconnectCleanup() { + var session = RemoteDesktopSessionState(leaseID: "lease-1", now: Date(timeIntervalSince1970: 100)) + + session.connected(now: Date(timeIntervalSince1970: 101)) + session.updateTransportPath(.direct) + XCTAssertEqual(session.transportPath, .direct) + session.enterBackground(now: Date(timeIntervalSince1970: 110)) + XCTAssertEqual(session.phase, .suspended) + XCTAssertFalse(session.shouldRequireNewLease(now: Date(timeIntervalSince1970: 130))) + XCTAssertTrue(session.shouldRequireNewLease(now: Date(timeIntervalSince1970: 141))) + + session.enterForeground(now: Date(timeIntervalSince1970: 130)) + XCTAssertEqual(session.phase, .reconnecting) + session.updateTransportPath(.relayed) + XCTAssertEqual(session.transportPath, .relayed) + + session.disconnect() + XCTAssertEqual(session.phase, .disconnected) + XCTAssertEqual(session.transportPath, .unknown) + XCTAssertTrue(session.pendingInputs.isEmpty) + } + + func testViewportCentersCursorAndClampsAtDisplayEdges() { + let container = CGSize(width: 100, height: 100) + let image = CGSize(width: 100, height: 100) + + var viewport = RemoteViewport(zoom: 2, cursor: CGPoint(x: 0.5, y: 0.5)) + XCTAssertEqual(viewport.offset(container: container, image: image), .zero) + XCTAssertEqual(viewport.cursorPosition(container: container, image: image), CGPoint(x: 50, y: 50)) + + viewport.cursor = CGPoint(x: 0.75, y: 0.5) + XCTAssertEqual(viewport.offset(container: container, image: image), CGSize(width: -50, height: 0)) + XCTAssertEqual(viewport.cursorPosition(container: container, image: image), CGPoint(x: 50, y: 50)) + + viewport.cursor = CGPoint(x: 1, y: 1) + XCTAssertEqual(viewport.offset(container: container, image: image), CGSize(width: -50, height: -50)) + XCTAssertEqual(viewport.cursorPosition(container: container, image: image), CGPoint(x: 100, y: 100)) + } + + func testViewportCursorMarkerAppliesHotspotCompensation() { + let viewport = RemoteViewport(cursor: CGPoint(x: 0.5, y: 0.5)) + + XCTAssertEqual( + viewport.cursorMarkerPosition( + container: CGSize(width: 100, height: 100), + image: CGSize(width: 100, height: 100) + ), + CGPoint(x: 50, y: 65) + ) + } + + func testViewportPointerPredictionUsesMacDisplayCoordinateSpace() { + var viewport = RemoteViewport(cursor: CGPoint(x: 0.5, y: 0.5)) + + viewport.applyPointerDelta( + CGSize(width: 100, height: -50), + coordinateSize: CGSize(width: 1_000, height: 500), + sensitivity: 1 + ) + + XCTAssertEqual(viewport.cursor.x, 0.6, accuracy: 0.0001) + XCTAssertEqual(viewport.cursor.y, 0.4, accuracy: 0.0001) + } + + func testViewportConvertsScreenDeltaToRemoteDeltaUsingZoomAndRotationLayout() { + let image = CGSize(width: 1_920, height: 1_080) + + let portrait = RemoteViewport(zoom: 1).remoteDelta( + forScreenDelta: CGSize(width: 10, height: 10), + container: CGSize(width: 390, height: 844), + image: image + ) + XCTAssertEqual(portrait.width, 49.2308, accuracy: 0.0001) + XCTAssertEqual(portrait.height, 49.2308, accuracy: 0.0001) + + let landscapeZoomed = RemoteViewport(zoom: 2).remoteDelta( + forScreenDelta: CGSize(width: 10, height: 10), + container: CGSize(width: 844, height: 390), + image: image + ) + XCTAssertEqual(landscapeZoomed.width, 13.8462, accuracy: 0.0001) + XCTAssertEqual(landscapeZoomed.height, 13.8462, accuracy: 0.0001) + } + + private func assertRoundTrip( + _ value: T, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let encoded = try wireEncoder().encode(value) + let decoded = try wireDecoder().decode(T.self, from: encoded) + XCTAssertEqual(decoded, value, file: file, line: line) + } + + private func wireEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.dataEncodingStrategy = .base64 + return encoder + } + + private func wireDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.dataDecodingStrategy = .base64 + return decoder + } +} diff --git a/ops/agents/prompts/community-drafts.md b/ops/agents/prompts/community-drafts.md new file mode 100644 index 00000000..f24220a8 --- /dev/null +++ b/ops/agents/prompts/community-drafts.md @@ -0,0 +1,18 @@ +# CodePilot Community Drafts + +Draft transparent beta-focused community promotion for CodePilot using the `codepilotios` identity. + +Follow `docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md`. + +Rules: +- Do not create accounts. +- Do not post publicly. +- Do not imply paid subscription details. +- Follow Reddit and subreddit rules. +- Draft posts and comments only. +- If intervention is needed, write a concise escalation note to `ops/agents/escalations/community-drafts.md`. + +Report: +- Communities researched. +- Drafts prepared. +- Subreddit rules that require approval. diff --git a/ops/agents/prompts/health-watch.md b/ops/agents/prompts/health-watch.md new file mode 100644 index 00000000..8a91e591 --- /dev/null +++ b/ops/agents/prompts/health-watch.md @@ -0,0 +1,18 @@ +# CodePilot Health Watch + +Check CodePilot gateway health, Cloudflare tunnel reachability, OTA install status, and release readiness. + +Follow `docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md`. + +Rules: +- Do not post publicly. +- Do not change credentials. +- Do not submit App Store builds. +- Create draft GitHub issues or PRs for actionable findings when safe. +- If intervention is needed, write a concise escalation note to `ops/agents/escalations/health-watch.md`. + +Report: +- Checks performed. +- Findings. +- Issues or PRs created. +- Escalations written. diff --git a/ops/agents/prompts/issue-triage.md b/ops/agents/prompts/issue-triage.md new file mode 100644 index 00000000..74da5489 --- /dev/null +++ b/ops/agents/prompts/issue-triage.md @@ -0,0 +1,17 @@ +# CodePilot Issue Triage + +Triage new CodePilot GitHub issues for the Mac app, iOS app, gateway, release tooling, setup flow, and public docs. + +Follow `docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md`. + +Rules: +- Label issues by component and severity when GitHub access is available. +- Reproduce actionable bugs where possible. +- Create focused draft PRs for low-risk fixes. +- Do not make pricing, legal, App Store, credential, or public-posting decisions. +- If intervention is needed, write a concise escalation note to `ops/agents/escalations/issue-triage.md`. + +Report: +- Issues reviewed. +- Labels/comments/PRs created. +- Blockers. diff --git a/ops/agents/prompts/presence-maintenance.md b/ops/agents/prompts/presence-maintenance.md new file mode 100644 index 00000000..b7c73533 --- /dev/null +++ b/ops/agents/prompts/presence-maintenance.md @@ -0,0 +1,17 @@ +# CodePilot Presence Maintenance + +Maintain CodePilot public presence: GitHub Pages, README, docs, screenshots, changelog, FAQ, privacy copy, and support copy. + +Follow `docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md`. + +Rules: +- Keep promotion beta-focused. +- Target AI coding users and existing Codex CLI users. +- Remove private names, hosts, paths, tokens, and private screenshots. +- Create draft PRs for public content changes. +- If intervention is needed, write a concise escalation note to `ops/agents/escalations/presence-maintenance.md`. + +Report: +- Content reviewed. +- PRs/issues created. +- Approval needed. diff --git a/ops/agents/prompts/release-readiness.md b/ops/agents/prompts/release-readiness.md new file mode 100644 index 00000000..260d8b22 --- /dev/null +++ b/ops/agents/prompts/release-readiness.md @@ -0,0 +1,16 @@ +# CodePilot Release Readiness + +Check whether CodePilot is ready for OTA/TestFlight/App Store release work. + +Follow `docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md`. + +Rules: +- Run relevant tests and builds when safe. +- Prepare release notes and blocker summaries. +- Do not submit App Store review or alter pricing/legal metadata without Tony. +- If intervention is needed, write a concise escalation note to `ops/agents/escalations/release-readiness.md`. + +Report: +- Build/test status. +- Release blockers. +- Metadata or screenshot gaps. diff --git a/ops/agents/prompts/security-scan.md b/ops/agents/prompts/security-scan.md new file mode 100644 index 00000000..daf79338 --- /dev/null +++ b/ops/agents/prompts/security-scan.md @@ -0,0 +1,16 @@ +# CodePilot Security Scan + +Scan CodePilot for secrets, private data, unsafe remote access, auth token exposure, public repo leaks, and gateway risks. + +Follow `docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md`. + +Rules: +- Treat secrets, auth tokens, remote desktop access, uploads, and public endpoints as high risk. +- Create draft PRs for hardening when safe. +- Do not disclose secrets in public issues. +- If intervention is needed, write a concise escalation note to `ops/agents/escalations/security-scan.md`. + +Report: +- Scope scanned. +- Findings by severity. +- Fixes or private escalations. diff --git a/ops/agents/prompts/setup-audit.md b/ops/agents/prompts/setup-audit.md new file mode 100644 index 00000000..2ca65beb --- /dev/null +++ b/ops/agents/prompts/setup-audit.md @@ -0,0 +1,16 @@ +# CodePilot Setup Audit + +Audit CodePilot first-run setup for the Mac app, iOS app, gateway, Cloudflare, permissions, account setup, and docs. + +Follow `docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md`. + +Rules: +- File issues for setup friction. +- Fix low-risk copy or validation problems via draft PRs. +- Do not change auth secrets or create external accounts. +- If intervention is needed, write a concise escalation note to `ops/agents/escalations/setup-audit.md`. + +Report: +- Setup paths checked. +- Friction found. +- Fixes or issues created. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 3d6c7d6a..66b25877 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -6,13 +6,18 @@ APP="$ROOT/build/CodePilot.app" CONTENTS="$APP/Contents" MACOS="$CONTENTS/MacOS" RESOURCES="$CONTENTS/Resources" +FRAMEWORKS="$CONTENTS/Frameworks" cd "$ROOT" swift build -c release rm -rf "$APP" -mkdir -p "$MACOS" "$RESOURCES/scripts" +mkdir -p "$MACOS" "$RESOURCES/scripts" "$FRAMEWORKS" cp "$ROOT/.build/release/CodexAccountSwitcher" "$MACOS/CodePilot" +if [[ -d "$ROOT/.build/arm64-apple-macosx/release/LiveKitWebRTC.framework" ]]; then + cp -R "$ROOT/.build/arm64-apple-macosx/release/LiveKitWebRTC.framework" "$FRAMEWORKS/" + install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS/CodePilot" 2>/dev/null || true +fi cp "$ROOT/scripts/"*.sh "$RESOURCES/scripts/" cp "$ROOT/scripts/"*.py "$RESOURCES/scripts/" 2>/dev/null || true chmod +x "$RESOURCES/scripts/"*.sh @@ -44,4 +49,17 @@ cat > "$CONTENTS/Info.plist" <<'PLIST' PLIST +SIGNING_IDENTITY="${CODEPILOT_SIGNING_IDENTITY:-}" +if [[ -z "$SIGNING_IDENTITY" ]]; then + SIGNING_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | awk '/Apple Development:/{print $2; exit}')" +fi +if [[ -n "$SIGNING_IDENTITY" ]]; then + if [[ -d "$FRAMEWORKS/LiveKitWebRTC.framework" ]]; then + codesign --force --timestamp=none --sign "$SIGNING_IDENTITY" "$FRAMEWORKS/LiveKitWebRTC.framework" + fi + codesign --force --timestamp=none --options runtime --identifier io.codepilot.mac --sign "$SIGNING_IDENTITY" "$APP" +else + echo "warning: no Apple Development signing identity found; macOS permissions may reset after rebuilds" >&2 +fi + echo "$APP" diff --git a/scripts/codepilot-agent-runner.sh b/scripts/codepilot-agent-runner.sh new file mode 100755 index 00000000..cacde95e --- /dev/null +++ b/scripts/codepilot-agent-runner.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env zsh +set -euo pipefail + +ROOT="${CODEPILOT_REPO_ROOT:-/Users/homeserver/Developer/CodexAccountSwitcher}" +JOB="${1:-}" + +if [[ -z "$JOB" ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +PROMPT="$ROOT/ops/agents/prompts/$JOB.md" +LOG_DIR="${CODEPILOT_AGENT_LOG_DIR:-$HOME/Library/Logs/CodePilotAgents}" +LOCK_DIR="${TMPDIR:-/tmp}/codepilot-agent-$JOB.lock" + +if [[ ! -f "$PROMPT" ]]; then + echo "Unknown CodePilot agent job: $JOB" >&2 + exit 66 +fi + +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "CodePilot agent already running: $JOB" + exit 0 +fi +trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT + +mkdir -p "$LOG_DIR" "$ROOT/ops/agents/escalations" + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +log_file="$LOG_DIR/$JOB-$timestamp.log" + +exec >>"$log_file" 2>&1 + +echo "== CodePilot agent: $JOB ==" +echo "started_at=$timestamp" +echo "repo=$ROOT" + +cd "$ROOT" + +codex exec \ + --cd "$ROOT" \ + --sandbox danger-full-access \ + --ask-for-approval never \ + - < "$PROMPT" + +echo "finished_at=$(date -u +%Y%m%dT%H%M%SZ)" diff --git a/scripts/install-codepilot-local-agents.sh b/scripts/install-codepilot-local-agents.sh new file mode 100755 index 00000000..273c47a3 --- /dev/null +++ b/scripts/install-codepilot-local-agents.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env zsh +set -euo pipefail + +ROOT="${CODEPILOT_REPO_ROOT:-/Users/homeserver/Developer/CodexAccountSwitcher}" +PLIST_DIR="$HOME/Library/LaunchAgents" +RUNNER="$ROOT/scripts/codepilot-agent-runner.sh" + +if [[ "${1:-}" != "--install" ]]; then + cat <<'EOF' +This installs local LaunchAgents for CodePilot background agents. + +Important limitation: +Local LaunchAgents cannot ping the current Codex thread. They write logs to +~/Library/Logs/CodePilotAgents and intervention notes to ops/agents/escalations. + +Run with --install only if that fallback escalation path is acceptable. +EOF + exit 64 +fi + +chmod +x "$RUNNER" +mkdir -p "$PLIST_DIR" + +write_plist() { + local name="$1" + local interval="$2" + local plist="$PLIST_DIR/io.codepilot.agent.$name.plist" + cat > "$plist" < + + + + Label + io.codepilot.agent.$name + ProgramArguments + + $RUNNER + $name + + StartInterval + $interval + RunAtLoad + + StandardOutPath + $HOME/Library/Logs/CodePilotAgents/$name.launchd.out.log + StandardErrorPath + $HOME/Library/Logs/CodePilotAgents/$name.launchd.err.log + + +EOF + launchctl unload "$plist" 2>/dev/null || true + launchctl load "$plist" +} + +write_plist health-watch 3600 +write_plist issue-triage 7200 +write_plist setup-audit 86400 +write_plist release-readiness 86400 +write_plist presence-maintenance 604800 +write_plist community-drafts 604800 +write_plist security-scan 604800 + +echo "Installed CodePilot local LaunchAgents." diff --git a/scripts/install-phone-cloudflared-agent.sh b/scripts/install-phone-cloudflared-agent.sh index be7d5eaa..f473eda1 100755 --- a/scripts/install-phone-cloudflared-agent.sh +++ b/scripts/install-phone-cloudflared-agent.sh @@ -2,35 +2,4 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -LABEL="${CODEPILOT_CLOUDFLARED_LAUNCHD_LABEL:-io.codepilot.phone-cloudflared}" -PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" -mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" - -/usr/bin/python3 - "$ROOT" "$PLIST" "$LABEL" <<'PY' -import plistlib -import sys -from pathlib import Path - -root = Path(sys.argv[1]) -plist_path = Path(sys.argv[2]) -label = sys.argv[3] -plist = { - "Label": label, - "ProgramArguments": [ - str(root / "scripts" / "start-phone-cloudflared.sh"), - ], - "RunAtLoad": True, - "KeepAlive": True, - "WorkingDirectory": str(root), - "StandardOutPath": str(Path.home() / "Library" / "Logs" / "codex-phone-cloudflared.out.log"), - "StandardErrorPath": str(Path.home() / "Library" / "Logs" / "codex-phone-cloudflared.err.log"), -} -plist_path.write_bytes(plistlib.dumps(plist, sort_keys=False)) -PY - -launchctl bootout "gui/$(id -u)" "$PLIST" >/dev/null 2>&1 || true -launchctl bootstrap "gui/$(id -u)" "$PLIST" -launchctl enable "gui/$(id -u)/$LABEL" -launchctl kickstart -k "gui/$(id -u)/$LABEL" - -echo "Installed $LABEL" +exec "$ROOT/scripts/setup-cloudflare-remote-access.sh" install-service diff --git a/scripts/setup-cloudflare-remote-access.sh b/scripts/setup-cloudflare-remote-access.sh new file mode 100755 index 00000000..85cec22d --- /dev/null +++ b/scripts/setup-cloudflare-remote-access.sh @@ -0,0 +1,288 @@ +#!/bin/zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_DIR="$HOME/.codex-account-switcher" +CLOUDFLARED_DIR="$HOME/.cloudflared" +CONFIG_PATH="$CLOUDFLARED_DIR/codepilot-config.yaml" +LEGACY_CONFIG_PATH="$CLOUDFLARED_DIR/codex-phone-config.yaml" +METADATA_PATH="$APP_DIR/cloudflare-setup.json" +LABEL="${CODEPILOT_CLOUDFLARED_LAUNCHD_LABEL:-io.codepilot.phone-cloudflared}" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +GATEWAY_URL="${CODEPILOT_GATEWAY_URL:-http://127.0.0.1:18790}" + +mkdir -p "$APP_DIR" "$CLOUDFLARED_DIR" + +cloudflared_bin() { + if command -v cloudflared >/dev/null 2>&1; then + command -v cloudflared + return 0 + fi + if [ -x /opt/homebrew/bin/cloudflared ]; then + echo /opt/homebrew/bin/cloudflared + return 0 + fi + if [ -x /usr/local/bin/cloudflared ]; then + echo /usr/local/bin/cloudflared + return 0 + fi + return 1 +} + +brew_bin() { + if command -v brew >/dev/null 2>&1; then + command -v brew + return 0 + fi + if [ -x /opt/homebrew/bin/brew ]; then + echo /opt/homebrew/bin/brew + return 0 + fi + if [ -x /usr/local/bin/brew ]; then + echo /usr/local/bin/brew + return 0 + fi + return 1 +} + +write_metadata() { + local mode="$1" + local hostname="$2" + local tunnel_name="$3" + local tunnel_id="$4" + /usr/bin/python3 - "$METADATA_PATH" "$mode" "$hostname" "$tunnel_name" "$tunnel_id" "$CONFIG_PATH" "$LABEL" <<'PY' +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +path = Path(sys.argv[1]) +payload = { + "mode": sys.argv[2], + "hostname": sys.argv[3], + "tunnelName": sys.argv[4], + "tunnelId": sys.argv[5], + "configPath": sys.argv[6], + "launchAgentLabel": sys.argv[7], + "lastVerifiedAt": None, + "updatedAt": datetime.now(timezone.utc).isoformat(), +} +path.parent.mkdir(parents=True, exist_ok=True) +path.write_text(json.dumps(payload, indent=2) + "\n") +PY +} + +status() { + local cf="" + local brew="" + cf="$(cloudflared_bin 2>/dev/null || true)" + brew="$(brew_bin 2>/dev/null || true)" + /usr/bin/python3 - "$cf" "$brew" "$CONFIG_PATH" "$LEGACY_CONFIG_PATH" "$METADATA_PATH" "$PLIST" <<'PY' +import json +import sys +from pathlib import Path + +cf, brew, config, legacy, metadata, plist = sys.argv[1:] +print(json.dumps({ + "cloudflaredPath": cf or None, + "homebrewPath": brew or None, + "configPath": config if Path(config).exists() else None, + "legacyConfigPath": legacy if Path(legacy).exists() else None, + "metadataPath": metadata if Path(metadata).exists() else None, + "launchAgentPath": plist if Path(plist).exists() else None, +}, indent=2)) +PY +} + +install_cloudflared() { + if cloudflared_bin >/dev/null 2>&1; then + echo "cloudflared already installed at $(cloudflared_bin)" + return 0 + fi + + local brew + brew="$(brew_bin)" || { + echo "Homebrew is missing. Install Homebrew or install cloudflared manually from Cloudflare." >&2 + exit 20 + } + "$brew" install cloudflared +} + +login() { + local cf + cf="$(cloudflared_bin)" || { + echo "cloudflared is missing." >&2 + exit 21 + } + "$cf" tunnel login +} + +parse_tunnel_id() { + /usr/bin/python3 -c ' +import json +import re +import sys + +text = sys.stdin.read() +try: + decoded = json.loads(text) + if isinstance(decoded, dict) and decoded.get("id"): + print(decoded["id"]) + raise SystemExit +except Exception: + pass + +match = re.search(r"[0-9a-fA-F-]{20,}", text) +print(match.group(0) if match else "") +' +} + +configure_permanent() { + local hostname="" + local tunnel_name="codepilot" + while [ $# -gt 0 ]; do + case "$1" in + --hostname) + hostname="$2" + shift 2 + ;; + --tunnel-name) + tunnel_name="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 2 + ;; + esac + done + [ -n "$hostname" ] || { + echo "--hostname is required" >&2 + exit 2 + } + + local cf create_output tunnel_id + cf="$(cloudflared_bin)" || { + echo "cloudflared is missing." >&2 + exit 21 + } + create_output="$("$cf" tunnel create "$tunnel_name" 2>&1 || true)" + tunnel_id="$(printf '%s' "$create_output" | parse_tunnel_id)" + if [ -z "$tunnel_id" ]; then + echo "Could not determine Cloudflare tunnel ID for '$tunnel_name'." >&2 + if [ -n "$create_output" ]; then + printf '%s\n' "$create_output" >&2 + fi + echo "Choose another tunnel name or remove the existing tunnel, then rerun configure-permanent." >&2 + exit 22 + fi + "$cf" tunnel route dns "$tunnel_name" "$hostname" + + cat > "$CONFIG_PATH" </dev/null 2>&1 || true + launchctl bootstrap "gui/$(id -u)" "$PLIST" + launchctl enable "gui/$(id -u)/$LABEL" + launchctl kickstart -k "gui/$(id -u)/$LABEL" + echo "Installed $LABEL" +} + +verify_url() { + local url="" + while [ $# -gt 0 ]; do + case "$1" in + --url) + url="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 2 + ;; + esac + done + [ -n "$url" ] || { + echo "--url is required" >&2 + exit 2 + } + curl -fsS "$url/api/health" >/dev/null + echo "verified $url" +} + +start_trycloudflare() { + local cf + cf="$(cloudflared_bin)" || { + echo "cloudflared is missing." >&2 + exit 21 + } + "$cf" tunnel --url "$GATEWAY_URL" +} + +case "${1:-}" in + status) + status + ;; + install-cloudflared) + install_cloudflared + ;; + login) + login + ;; + configure-permanent) + shift + configure_permanent "$@" + ;; + install-service) + install_service + ;; + restart-service) + install_service + ;; + verify) + shift + verify_url "$@" + ;; + start-trycloudflare) + start_trycloudflare + ;; + *) + echo "Usage: $0 status|install-cloudflared|login|configure-permanent|install-service|restart-service|verify|start-trycloudflare" >&2 + exit 2 + ;; +esac diff --git a/scripts/start-phone-cloudflared.sh b/scripts/start-phone-cloudflared.sh index dd31332b..d713b0b1 100755 --- a/scripts/start-phone-cloudflared.sh +++ b/scripts/start-phone-cloudflared.sh @@ -1,4 +1,14 @@ #!/bin/zsh set -euo pipefail -exec /opt/homebrew/bin/cloudflared tunnel --config "$HOME/.cloudflared/codex-phone-config.yaml" run +CONFIG="$HOME/.cloudflared/codepilot-config.yaml" +LEGACY="$HOME/.cloudflared/codex-phone-config.yaml" +if [ ! -f "$CONFIG" ] && [ -f "$LEGACY" ]; then + CONFIG="$LEGACY" +fi + +if command -v cloudflared >/dev/null 2>&1; then + exec "$(command -v cloudflared)" tunnel --config "$CONFIG" run +fi + +exec /opt/homebrew/bin/cloudflared tunnel --config "$CONFIG" run diff --git a/scripts/test-cloudflare-setup.sh b/scripts/test-cloudflare-setup.sh new file mode 100755 index 00000000..4bfa2c7a --- /dev/null +++ b/scripts/test-cloudflare-setup.sh @@ -0,0 +1,90 @@ +#!/bin/zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$ROOT/scripts/setup-cloudflare-remote-access.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +export HOME="$TMP/home" +export PATH="$TMP/bin:/usr/bin:/bin:/usr/sbin:/sbin" +mkdir -p "$HOME" "$TMP/bin" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_stub() { + local name="$1" + local body="$2" + { + echo '#!/bin/zsh' + echo 'set -euo pipefail' + printf '%s\n' "$body" + } > "$TMP/bin/$name" + chmod +x "$TMP/bin/$name" +} + +write_stub cloudflared ' +case "$*" in + "--version") echo "cloudflared version 2026.6.1";; + "tunnel list --output json") echo "[]";; + "tunnel create codepilot") echo "{\"id\":\"tun_123\",\"name\":\"codepilot\"}";; + "tunnel route dns codepilot codepilot.example.com") echo "Added CNAME codepilot.example.com";; + "tunnel --url http://127.0.0.1:18790") echo "https://temporary.trycloudflare.com";; + *) echo "cloudflared $*";; +esac +' +write_stub brew 'echo "brew $*"' +write_stub launchctl 'echo "launchctl $*"' +write_stub curl 'echo "{\"ok\":true}"' + +"$SCRIPT" status >/tmp/codepilot-status.json 2>/tmp/codepilot-status.err || true +grep -q "No such file" /tmp/codepilot-status.err && fail "status should not crash when config is missing" + +"$SCRIPT" configure-permanent --hostname codepilot.example.com --tunnel-name codepilot +[ -f "$HOME/.cloudflared/codepilot-config.yaml" ] || fail "config file missing" +grep -q "hostname: codepilot.example.com" "$HOME/.cloudflared/codepilot-config.yaml" || fail "hostname missing from config" +grep -q "service: http://127.0.0.1:18790" "$HOME/.cloudflared/codepilot-config.yaml" || fail "gateway service missing from config" +[ -f "$HOME/.codex-account-switcher/cloudflare-setup.json" ] || fail "metadata missing" +! grep -qi "token" "$HOME/.codex-account-switcher/cloudflare-setup.json" || fail "metadata must not contain token" + +rm -f "$HOME/.cloudflared/codepilot-config.yaml" "$HOME/.codex-account-switcher/cloudflare-setup.json" +write_stub cloudflared ' +case "$*" in + "--version") echo "cloudflared version 2026.6.1";; + "tunnel list --output json") echo "[]";; + "tunnel create existing") echo "Tunnel already exists" >&2; exit 1;; + "tunnel route dns existing existing.example.com") echo "Added CNAME existing.example.com";; + *) echo "cloudflared $*";; +esac +' +if "$SCRIPT" configure-permanent --hostname existing.example.com --tunnel-name existing >/tmp/codepilot-no-id.out 2>/tmp/codepilot-no-id.err; then + fail "configure-permanent should fail when no tunnel ID can be determined" +fi +[ ! -f "$HOME/.cloudflared/codepilot-config.yaml" ] || fail "config should not be written without a tunnel ID" +[ ! -f "$HOME/.codex-account-switcher/cloudflare-setup.json" ] || fail "metadata should not be written without a tunnel ID" +grep -qi "tunnel ID" /tmp/codepilot-no-id.err || fail "no-ID failure should explain the missing tunnel ID" + +write_stub cloudflared ' +case "$*" in + "--version") echo "cloudflared version 2026.6.1";; + "tunnel list --output json") echo "[]";; + "tunnel create codepilot") echo "{\"id\":\"tun_123\",\"name\":\"codepilot\"}";; + "tunnel route dns codepilot codepilot.example.com") echo "Added CNAME codepilot.example.com";; + "tunnel --url http://127.0.0.1:18790") echo "https://temporary.trycloudflare.com";; + *) echo "cloudflared $*";; +esac +' + +"$SCRIPT" install-service +[ -f "$HOME/Library/LaunchAgents/io.codepilot.phone-cloudflared.plist" ] || fail "LaunchAgent plist missing" + +"$SCRIPT" verify --url https://codepilot.example.com >/tmp/codepilot-verify.out +grep -q "verified" /tmp/codepilot-verify.out || fail "verify output should say verified" + +"$SCRIPT" start-trycloudflare >/tmp/codepilot-try.out +grep -q "temporary.trycloudflare.com" /tmp/codepilot-try.out || fail "temporary URL missing" + +echo "PASS: cloudflare setup script tests"