From 5c9bf526f3215fd0cd8cb29230fb7ec8e76db402 Mon Sep 17 00:00:00 2001 From: codepilotios Date: Thu, 2 Jul 2026 07:38:47 +0200 Subject: [PATCH 01/57] [codex] Guard Cloudflare tunnel setup without ID (#9) * docs: design secure remote desktop * docs: plan secure remote desktop * chore: ignore local worktrees * build: add remote desktop wire contracts * fix: finalize remote desktop lease revocation * feat: add remote desktop native IPC * fix: harden remote desktop native IPC * feat: add remote desktop gateway APIs * feat: add remote desktop trust management UI * feat: stream and control the Mac desktop * feat: pair trusted CodePilot devices * feat: add iPhone remote desktop controls * feat: harden remote desktop sessions * docs: specify WebRTC remote desktop transport * feat: add secure remote desktop foundation * feat: follow remote cursor while zoomed * feat: stream display frames with ScreenCaptureKit * feat: negotiate Mac WebRTC remote desktop peer * feat: render WebRTC remote desktop video on iOS * feat: send remote desktop input over WebRTC * fix: smooth remote desktop cursor overlay * fix: align remote cursor coordinate space * fix: sync remote cursor from host * fix: align remote cursor hotspot * fix: scale remote pointer deltas by viewport * fix: use host cursor for remote desktop video * fix: remove remote desktop cursor overlay * fix: show host cursor marker * fix: preserve remote input ordering * fix: click at remote cursor marker * fix: align click injection with remote cursor * fix: match WebRTC video layout to viewport * fix: align remote desktop cursor marker * fix: lower remote desktop cursor marker * fix: preserve chat message line breaks * feat: open Mac localhost URLs in iOS app * docs: specify CodePilot production readiness * docs: plan CodePilot production readiness work * feat: expose stable gateway health and errors * feat: clarify CodePilot setup and gateway operations * feat: add CodePilot iOS connection setup * docs: prepare CodePilot public beta guides * test: isolate gateway tests from local account state * docs: specify Cloudflare setup wizard * docs: plan Cloudflare setup wizard * feat: add Cloudflare setup automation * feat: model Cloudflare setup state * feat: add Cloudflare setup wizard * docs: document Cloudflare setup wizard * docs: plan launch agent system * fix: open markdown file links in codepilot app * fix: add dismiss controls to file preview * docs: record codepilot launch decisions * chore: add codepilot agent fallback runner * Guard Cloudflare tunnel setup without ID --------- Co-authored-by: CodePilot --- .gitignore | 1 + Package.resolved | 14 + Package.swift | 8 +- README.md | 3 +- .../RemoteDesktop/InputInjector.swift | 182 ++++ .../RemoteDesktop/MacPeerConnection.swift | 239 +++++ .../RemoteDesktop/PairingStore.swift | 424 ++++++++ .../RemoteDesktop/RemoteDesktopAuditLog.swift | 149 +++ .../RemoteDesktopCoordinator.swift | 245 +++++ .../RemoteDesktop/RemoteDesktopModels.swift | 141 +++ .../RemoteDesktopSocketServer.swift | 368 +++++++ .../RemoteDesktopWindowController.swift | 176 ++++ .../RemoteFrameCaptureService.swift | 43 + .../RemoteDesktop/ScreenCaptureService.swift | 89 ++ .../RemoteDesktop/SessionLeaseStore.swift | 279 ++++++ .../RemoteDesktop/WebRTCFrameAdapter.swift | 22 + Sources/CodexAccountSwitcher/main.swift | 936 +++++++++++++++++- .../ActiveTurnTrackerTests.swift | 32 + .../CloudflareSetupTests.swift | 38 + .../InputInjectorTests.swift | 207 ++++ .../MacPeerConnectionTests.swift | 45 + .../PairingStoreTests.swift | 211 ++++ .../RemoteDesktopAuditLogTests.swift | 119 +++ .../RemoteDesktopCoordinatorTests.swift | 132 +++ .../RemoteDesktopModelsTests.swift | 143 +++ .../RemoteDesktopSocketServerTests.swift | 335 +++++++ .../SessionLeaseStoreTests.swift | 282 ++++++ .../SetupStatusTests.swift | 17 + docs/ARCHITECTURE.md | 2 +- docs/CLOUDFLARE_SETUP.md | 57 +- docs/INSTALL_IOS.md | 10 +- docs/INSTALL_MAC.md | 9 + docs/PRODUCTION_READINESS.md | 263 +++++ ...RODUCTION_READINESS_IMPLEMENTATION_PLAN.md | 543 ++++++++++ docs/RELEASE_CHECKLIST.md | 12 + docs/SECURITY.md | 3 + docs/TROUBLESHOOTING.md | 45 + docs/operations/continuous-agents.md | 27 + .../plans/2026-06-27-secure-remote-desktop.md | 646 ++++++++++++ .../plans/2026-06-28-webrtc-remote-desktop.md | 157 +++ .../2026-06-30-cloudflare-setup-wizard.md | 907 +++++++++++++++++ ...026-07-01-codepilot-launch-agent-system.md | 294 ++++++ ...2026-06-27-secure-remote-desktop-design.md | 173 ++++ ...2026-06-28-webrtc-remote-desktop-design.md | 70 ++ ...26-06-29-cloudflare-setup-wizard-design.md | 193 ++++ gateway/codex_phone_gateway.py | 431 +++++++- gateway/remote_desktop_gateway.py | 470 +++++++++ gateway/test_codex_phone_gateway.py | 150 +++ gateway/test_remote_desktop_gateway.py | 573 +++++++++++ .../CodexPhone.xcodeproj/project.pbxproj | 194 +++- .../xcshareddata/swiftpm/Package.resolved | 15 + .../xcschemes/CodexPhone.xcscheme | 12 + ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 369 ++++++- .../RemoteDesktop/RemoteControlBar.swift | 38 + .../RemoteDesktop/RemoteDesktopAPI.swift | 231 +++++ .../RemoteDesktop/RemoteDesktopModels.swift | 89 ++ .../RemoteDesktop/RemoteDesktopSession.swift | 75 ++ .../RemoteDesktop/RemoteDesktopView.swift | 425 ++++++++ .../RemoteDesktop/RemoteDeviceIdentity.swift | 69 ++ .../RemoteDesktop/RemoteInputMapper.swift | 115 +++ .../RemoteDesktop/RemotePairingView.swift | 122 +++ .../RemoteDesktop/RemotePeerConnection.swift | 198 ++++ .../RemoteDesktop/RemoteVideoView.swift | 19 + .../RemoteDesktop/RemoteViewport.swift | 77 ++ .../CodexPhoneTests/RemoteDesktopTests.swift | 454 +++++++++ ops/agents/prompts/community-drafts.md | 18 + ops/agents/prompts/health-watch.md | 18 + ops/agents/prompts/issue-triage.md | 17 + ops/agents/prompts/presence-maintenance.md | 17 + ops/agents/prompts/release-readiness.md | 16 + ops/agents/prompts/security-scan.md | 16 + ops/agents/prompts/setup-audit.md | 16 + scripts/build-app.sh | 20 +- scripts/codepilot-agent-runner.sh | 46 + scripts/install-codepilot-local-agents.sh | 63 ++ scripts/install-phone-cloudflared-agent.sh | 33 +- scripts/setup-cloudflare-remote-access.sh | 288 ++++++ scripts/start-phone-cloudflared.sh | 12 +- scripts/test-cloudflare-setup.sh | 90 ++ 79 files changed, 12946 insertions(+), 141 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/InputInjector.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/MacPeerConnection.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/PairingStore.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopAuditLog.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopSocketServer.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/RemoteFrameCaptureService.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/ScreenCaptureService.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/SessionLeaseStore.swift create mode 100644 Sources/CodexAccountSwitcher/RemoteDesktop/WebRTCFrameAdapter.swift create mode 100644 Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/InputInjectorTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/MacPeerConnectionTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/PairingStoreTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/RemoteDesktopAuditLogTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/RemoteDesktopSocketServerTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/SessionLeaseStoreTests.swift create mode 100644 Tests/CodexAccountSwitcherTests/SetupStatusTests.swift create mode 100644 docs/PRODUCTION_READINESS.md create mode 100644 docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md create mode 100644 docs/RELEASE_CHECKLIST.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/operations/continuous-agents.md create mode 100644 docs/superpowers/plans/2026-06-27-secure-remote-desktop.md create mode 100644 docs/superpowers/plans/2026-06-28-webrtc-remote-desktop.md create mode 100644 docs/superpowers/plans/2026-06-30-cloudflare-setup-wizard.md create mode 100644 docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md create mode 100644 docs/superpowers/specs/2026-06-27-secure-remote-desktop-design.md create mode 100644 docs/superpowers/specs/2026-06-28-webrtc-remote-desktop-design.md create mode 100644 docs/superpowers/specs/2026-06-29-cloudflare-setup-wizard-design.md create mode 100644 gateway/remote_desktop_gateway.py create mode 100644 gateway/test_remote_desktop_gateway.py create mode 100644 ios/CodexPhone/CodexPhone.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteControlBar.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopSession.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDeviceIdentity.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteInputMapper.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteVideoView.swift create mode 100644 ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteViewport.swift create mode 100644 ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift create mode 100644 ops/agents/prompts/community-drafts.md create mode 100644 ops/agents/prompts/health-watch.md create mode 100644 ops/agents/prompts/issue-triage.md create mode 100644 ops/agents/prompts/presence-maintenance.md create mode 100644 ops/agents/prompts/release-readiness.md create mode 100644 ops/agents/prompts/security-scan.md create mode 100644 ops/agents/prompts/setup-audit.md create mode 100755 scripts/codepilot-agent-runner.sh create mode 100755 scripts/install-codepilot-local-agents.sh create mode 100755 scripts/setup-cloudflare-remote-access.sh create mode 100755 scripts/test-cloudflare-setup.sh 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" From b688bbdadd48c2465672197bb1399313b617c715 Mon Sep 17 00:00:00 2001 From: codepilotios Date: Thu, 2 Jul 2026 07:47:36 +0200 Subject: [PATCH 02/57] [codex] Fix setup audit friction (#6) * docs: design secure remote desktop * docs: plan secure remote desktop * chore: ignore local worktrees * build: add remote desktop wire contracts * fix: finalize remote desktop lease revocation * feat: add remote desktop native IPC * fix: harden remote desktop native IPC * feat: add remote desktop gateway APIs * feat: add remote desktop trust management UI * feat: stream and control the Mac desktop * feat: pair trusted CodePilot devices * feat: add iPhone remote desktop controls * feat: harden remote desktop sessions * docs: specify WebRTC remote desktop transport * feat: add secure remote desktop foundation * feat: follow remote cursor while zoomed * feat: stream display frames with ScreenCaptureKit * feat: negotiate Mac WebRTC remote desktop peer * feat: render WebRTC remote desktop video on iOS * feat: send remote desktop input over WebRTC * fix: smooth remote desktop cursor overlay * fix: align remote cursor coordinate space * fix: sync remote cursor from host * fix: align remote cursor hotspot * fix: scale remote pointer deltas by viewport * fix: use host cursor for remote desktop video * fix: remove remote desktop cursor overlay * fix: show host cursor marker * fix: preserve remote input ordering * fix: click at remote cursor marker * fix: align click injection with remote cursor * fix: match WebRTC video layout to viewport * fix: align remote desktop cursor marker * fix: lower remote desktop cursor marker * fix: preserve chat message line breaks * feat: open Mac localhost URLs in iOS app * docs: specify CodePilot production readiness * docs: plan CodePilot production readiness work * feat: expose stable gateway health and errors * feat: clarify CodePilot setup and gateway operations * feat: add CodePilot iOS connection setup * docs: prepare CodePilot public beta guides * test: isolate gateway tests from local account state * docs: specify Cloudflare setup wizard * docs: plan Cloudflare setup wizard * feat: add Cloudflare setup automation * feat: model Cloudflare setup state * feat: add Cloudflare setup wizard * docs: document Cloudflare setup wizard * docs: plan launch agent system * fix: open markdown file links in codepilot app * fix: add dismiss controls to file preview * docs: record codepilot launch decisions * chore: add codepilot agent fallback runner * chore: enable thread escalations for codepilot agents * Fix setup audit friction --------- Co-authored-by: CodePilot --- docs/INSTALL_IOS.md | 8 +++++ docs/INSTALL_MAC.md | 6 ++++ gateway/codex_phone_gateway.py | 8 ++--- gateway/test_codex_phone_gateway.py | 35 +++++++++++++++++++ ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 24 ++++++++----- .../CodexPhoneTests/RemoteDesktopTests.swift | 25 ++++++++++++- ops/agents/escalations/setup-audit.md | 5 +++ scripts/codepilot-agent-escalate.sh | 33 +++++++++++++++++ 8 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 ops/agents/escalations/setup-audit.md create mode 100755 scripts/codepilot-agent-escalate.sh diff --git a/docs/INSTALL_IOS.md b/docs/INSTALL_IOS.md index 76a555d0..3d4ba79d 100644 --- a/docs/INSTALL_IOS.md +++ b/docs/INSTALL_IOS.md @@ -22,6 +22,10 @@ Success means the app shows the active account from the Mac gateway. The app stores these values locally on the device. +For the default Mac setup, choose **Cloudflare** and enter the Cloudflare Tunnel URL. The Mac gateway listens on `127.0.0.1:18790` by default, which is only reachable from the Mac itself. Use **Same Network** only after configuring the gateway to listen on a Mac LAN address. + +The gateway URL must include `http://` or `https://`. + ## App Store Connect Setup The local Fastlane credentials live in the ignored file: @@ -51,3 +55,7 @@ The iOS app can register for turn-finished notifications. APNs certificates or k ## File Uploads Files selected in the iOS app are uploaded to the Mac gateway and saved under the CodePilot state directory. Image attachments can be passed into Codex turns when supported by the current runner. + +## Remote Desktop + +Remote Desktop requires the Mac app to show Screen Recording as granted before viewing and Accessibility as granted before control. If pairing reports missing permissions, grant them in macOS System Settings, restart CodePilot on the Mac, then retry pairing from the iOS app. diff --git a/docs/INSTALL_MAC.md b/docs/INSTALL_MAC.md index b9a827cc..00cf8f92 100644 --- a/docs/INSTALL_MAC.md +++ b/docs/INSTALL_MAC.md @@ -57,6 +57,8 @@ The gateway listens on: http://127.0.0.1:18790 ``` +That local address is for CodePilot on the Mac and for Cloudflare Tunnel. A physical iPhone cannot reach `127.0.0.1` on the Mac directly; use Cloudflare remote access for the default setup, or deliberately start the gateway on a LAN address before using the iOS **Same Network** option. + The bearer token is stored at: ```text @@ -68,3 +70,7 @@ Copy this token into the iOS app connection screen. Do not share it in issue rep ## Remote iPhone Access For access away from the local network, open **Setup CodePilot...** in the Mac menu bar app and use **Cloudflare Remote Access**. The setup wizard can install `cloudflared`, sign in to Cloudflare, configure a permanent hostname, or start a temporary TryCloudflare URL for testing. + +## Remote Desktop Permissions + +Open **Remote Desktop...** from the Mac menu bar app to check Screen Recording and Accessibility. Screen Recording is required to view the Mac, and Accessibility is required for pointer and keyboard control. Restart CodePilot after granting either macOS privacy permission. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index ec83b207..f51e15fe 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -4087,14 +4087,14 @@ def do_GET(self): ) return + if path == "/health" or path == "/api/health": + json_response(self, 200, self.state().public_health()) + return + if not self.authenticate(): return try: - if path == "/health" or path == "/api/health": - json_response(self, 200, self.state().public_health()) - return - if path == "/api/threads": json_response(self, 200, { "activeAccount": self.state().active_account_name(), diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index ec80e6a8..e7ed1fea 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -3,7 +3,9 @@ import io import subprocess import tempfile +import threading import tomllib +import urllib.request import unittest from pathlib import Path @@ -836,6 +838,39 @@ def test_public_health_exposes_safe_gateway_status(self): self.assertIn("localWeb", health) self.assertNotIn("secret-token", json.dumps(health)) + def test_health_endpoint_is_available_without_bearer_token(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + server = None + thread = None + try: + gateway.DEFAULT_SWITCHER_HOME = switcher_home + switcher_home.mkdir() + codex_home.mkdir() + (switcher_home / "active-account.txt").write_text("main", encoding="utf-8") + state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False) + server = gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + url = f"http://127.0.0.1:{server.server_address[1]}/api/health" + with urllib.request.urlopen(url, timeout=2) as response: + status = response.status + payload = json.loads(response.read().decode("utf-8")) + finally: + if server is not None: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=2) + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + + self.assertEqual(status, 200) + self.assertTrue(payload["gateway"]["running"]) + self.assertNotIn("secret-token", json.dumps(payload)) + def test_error_payload_has_stable_code_message_and_recovery(self): payload = gateway.error_payload( "local_web_invalid_target", diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index 77fd87df..29c4c8f5 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -373,7 +373,7 @@ enum GatewayConnectionKind: String, CaseIterable, Identifiable { var helpText: String { switch self { case .local: - "Use the Mac gateway URL while your iPhone is on the same Wi-Fi network." + "Use only after configuring the Mac gateway to listen on a LAN address." case .cloudflare: "Use your Cloudflare Tunnel hostname for access when you are away from the Mac." } @@ -1726,8 +1726,20 @@ func isMacLocalWebURL(_ url: URL) -> Bool { return host == "localhost" || host == "127.0.0.1" || host == "::1" } +func gatewayRootURL(from rawValue: String) throws -> URL { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = url.host, + !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw GatewayError.invalidURL + } + return url +} + func localWebSessionURL(path: String, baseURL: String) -> URL? { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)), + guard let root = try? gatewayRootURL(from: baseURL), var components = URLComponents(url: root, resolvingAgainstBaseURL: false) else { return nil } @@ -4633,9 +4645,7 @@ struct CodexGatewayClient { } func downloadRemoteFile(path: String, baseURL: String, token: String) async throws -> URL { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { - throw GatewayError.invalidURL - } + let root = try gatewayRootURL(from: baseURL) guard var components = URLComponents(url: root, resolvingAgainstBaseURL: false) else { throw GatewayError.invalidURL } @@ -4696,9 +4706,7 @@ struct CodexGatewayClient { baseURL: String, token: String ) async throws -> T { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { - throw GatewayError.invalidURL - } + let root = try gatewayRootURL(from: baseURL) guard let url = URL(string: path, relativeTo: root)?.absoluteURL else { throw GatewayError.invalidURL } diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift index 060136d2..98306811 100644 --- a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -17,7 +17,17 @@ final class RemoteDesktopTests: XCTestCase { func testGatewayConnectionKindUsesUserFacingTitles() { XCTAssertEqual(GatewayConnectionKind.local.title, "Same Network") XCTAssertEqual(GatewayConnectionKind.cloudflare.title, "Cloudflare") - XCTAssertTrue(GatewayConnectionKind.local.helpText.contains("same Wi-Fi")) + XCTAssertTrue(GatewayConnectionKind.local.helpText.contains("LAN address")) + XCTAssertTrue(GatewayConnectionKind.cloudflare.helpText.contains("Cloudflare Tunnel")) + } + + func testGatewayRootURLRequiresHTTPOrHTTPSWithHost() throws { + let url = try gatewayRootURL(from: " https://codepilot.example.com ") + + XCTAssertEqual(url.absoluteString, "https://codepilot.example.com") + assertInvalidGatewayRootURL("codepilot.example.com") + assertInvalidGatewayRootURL("file:///tmp/codepilot") + assertInvalidGatewayRootURL("http:///missing-host") } func testMacLocalWebURLDetectionOnlyAcceptsLoopbackHTTPURLs() throws { @@ -451,4 +461,17 @@ final class RemoteDesktopTests: XCTestCase { decoder.dataDecodingStrategy = .base64 return decoder } + + private func assertInvalidGatewayRootURL( + _ value: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError(try gatewayRootURL(from: value), file: file, line: line) { error in + guard case GatewayError.invalidURL = error else { + XCTFail("Expected GatewayError.invalidURL, got \(error)", file: file, line: line) + return + } + } + } } diff --git a/ops/agents/escalations/setup-audit.md b/ops/agents/escalations/setup-audit.md new file mode 100644 index 00000000..a96d4222 --- /dev/null +++ b/ops/agents/escalations/setup-audit.md @@ -0,0 +1,5 @@ +# Setup Audit Escalation + +Date: 2026-07-01 + +Remote Desktop pairing needs human review before public beta. The iOS pairing screen shows a pairing code field, but the current flow signs the challenge and the Mac approves the device immediately after `pairing.complete`; the Mac-side Approve/Reject UI is not part of the happy path. Because Remote Desktop controls the Mac, decide whether bearer-token possession is enough for pairing or whether pairing must require explicit Mac-side approval and clearer user copy. diff --git a/scripts/codepilot-agent-escalate.sh b/scripts/codepilot-agent-escalate.sh new file mode 100755 index 00000000..ca969283 --- /dev/null +++ b/scripts/codepilot-agent-escalate.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env zsh +set -euo pipefail + +ROOT="${CODEPILOT_REPO_ROOT:-/Users/homeserver/Developer/CodexAccountSwitcher}" +STATE_DIR="${CODEPILOT_AGENT_STATE_DIR:-$HOME/.codex-account-switcher/agents}" +THREAD_ID_FILE="$STATE_DIR/thread-id" +CODEX_BIN="${CODEPILOT_CODEX_BIN:-codex}" +JOB="${1:-manual}" +MESSAGE="${2:-}" + +mkdir -p "$STATE_DIR" + +THREAD_ID="${CODEPILOT_AGENT_THREAD_ID:-}" +if [[ -z "$THREAD_ID" && -f "$THREAD_ID_FILE" ]]; then + THREAD_ID="$(tr -d '[:space:]' < "$THREAD_ID_FILE")" +fi + +if [[ -z "$THREAD_ID" ]]; then + echo "No CodePilot agent thread id configured." >&2 + exit 66 +fi + +if [[ -z "$MESSAGE" ]]; then + MESSAGE="$(cat)" +fi + +{ + echo "A CodePilot continuous agent needs intervention." + echo + echo "Agent: $JOB" + echo + echo "$MESSAGE" +} | "$CODEX_BIN" exec resume "$THREAD_ID" - From 29100589bc0c3857aac1d771e6da711b7a445b3d Mon Sep 17 00:00:00 2001 From: codepilotios Date: Thu, 2 Jul 2026 07:55:31 +0200 Subject: [PATCH 03/57] [codex] prepare public beta presence (#7) * docs: design secure remote desktop * docs: plan secure remote desktop * chore: ignore local worktrees * build: add remote desktop wire contracts * fix: finalize remote desktop lease revocation * feat: add remote desktop native IPC * fix: harden remote desktop native IPC * feat: add remote desktop gateway APIs * feat: add remote desktop trust management UI * feat: stream and control the Mac desktop * feat: pair trusted CodePilot devices * feat: add iPhone remote desktop controls * feat: harden remote desktop sessions * docs: specify WebRTC remote desktop transport * feat: add secure remote desktop foundation * feat: follow remote cursor while zoomed * feat: stream display frames with ScreenCaptureKit * feat: negotiate Mac WebRTC remote desktop peer * feat: render WebRTC remote desktop video on iOS * feat: send remote desktop input over WebRTC * fix: smooth remote desktop cursor overlay * fix: align remote cursor coordinate space * fix: sync remote cursor from host * fix: align remote cursor hotspot * fix: scale remote pointer deltas by viewport * fix: use host cursor for remote desktop video * fix: remove remote desktop cursor overlay * fix: show host cursor marker * fix: preserve remote input ordering * fix: click at remote cursor marker * fix: align click injection with remote cursor * fix: match WebRTC video layout to viewport * fix: align remote desktop cursor marker * fix: lower remote desktop cursor marker * fix: preserve chat message line breaks * feat: open Mac localhost URLs in iOS app * docs: specify CodePilot production readiness * docs: plan CodePilot production readiness work * feat: expose stable gateway health and errors * feat: clarify CodePilot setup and gateway operations * feat: add CodePilot iOS connection setup * docs: prepare CodePilot public beta guides * test: isolate gateway tests from local account state * docs: specify Cloudflare setup wizard * docs: plan Cloudflare setup wizard * feat: add Cloudflare setup automation * feat: model Cloudflare setup state * feat: add Cloudflare setup wizard * docs: document Cloudflare setup wizard * docs: plan launch agent system * fix: open markdown file links in codepilot app * fix: add dismiss controls to file preview * docs: record codepilot launch decisions * chore: add codepilot agent fallback runner * docs: prepare public beta presence --------- Co-authored-by: CodePilot --- README.md | 20 +++++++--- .../RemoteDesktopCoordinatorTests.swift | 12 +++--- docs/CHANGELOG.md | 16 ++++++++ docs/FAQ.md | 33 +++++++++++++++ docs/INSTALL_IOS.md | 6 +++ docs/PRIVACY.md | 39 ++++++++++++++++++ ...RODUCTION_READINESS_IMPLEMENTATION_PLAN.md | 6 +-- docs/RELEASE_CHECKLIST.md | 2 + docs/SCREENSHOTS.md | 25 ++++++++++++ docs/SECURITY.md | 2 + docs/SUPPORT.md | 40 +++++++++++++++++++ docs/_config.yml | 4 ++ docs/index.md | 31 ++++++++++++++ .../plans/2026-06-27-secure-remote-desktop.md | 4 +- ...026-07-01-codepilot-launch-agent-system.md | 12 +++--- .../CodexPhoneTests/RemoteDesktopTests.swift | 14 +++---- .../escalations/presence-maintenance.md | 8 ++++ ops/agents/prompts/release-readiness.md | 2 +- scripts/codepilot-agent-escalate.sh | 2 +- scripts/codepilot-agent-runner.sh | 2 +- scripts/install-codepilot-local-agents.sh | 2 +- scripts/privacy-audit.sh | 13 ++++-- 22 files changed, 259 insertions(+), 36 deletions(-) create mode 100644 docs/CHANGELOG.md create mode 100644 docs/FAQ.md create mode 100644 docs/PRIVACY.md create mode 100644 docs/SCREENSHOTS.md create mode 100644 docs/SUPPORT.md create mode 100644 docs/_config.yml create mode 100644 docs/index.md create mode 100644 ops/agents/escalations/presence-maintenance.md diff --git a/README.md b/README.md index 947109d4..7cfddd46 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # CodePilot -CodePilot is a Mac menu bar app and iPhone companion for running coding-agent sessions from a Mac you control. It currently focuses on Codex account switching, usage visibility, remote iPhone access, file uploads, connector status, notifications, and gateway-backed conversation control. +CodePilot is a public-beta Mac menu bar app and iPhone companion for running coding-agent sessions from a Mac you control. It is built first for Codex CLI users who want mobile visibility, account switching, file uploads, connector status, notifications, and gateway-backed conversation control without exposing their Mac directly to the internet. -The project is designed to become provider-neutral over time, including future Claude Code compatibility. +The product name is provider-neutral, but the current implementation is Codex-focused. Future provider support, including Claude Code compatibility, is planned only after the Codex beta path is stable. ## License @@ -17,9 +17,13 @@ Commercial use requires a separate written license. See [LICENSE.md](LICENSE.md) - **CodePilot iOS**: an iPhone app that connects to the gateway for remote chat, file uploads, usage status, steering, stopping turns, and notifications. - **Cloudflare Tunnel support**: optional remote access to the gateway through a user-owned Cloudflare Tunnel. +## Beta Audience + +CodePilot is for AI coding users who already run Codex CLI on a Mac and are comfortable with beta software. The beta is focused on installation, connection reliability, safe remote access, and clear recovery steps. It does not make pricing, subscription, App Store availability, or future-provider compatibility promises. + ## Current Provider Support -CodePilot currently supports Codex by working with the local Codex home on the Mac: +CodePilot currently supports Codex by working with the local Codex home and CodePilot state directory on the Mac: - `~/.codex/auth.json` - `~/.codex/state_5.sqlite` @@ -50,9 +54,15 @@ The public product name is provider-neutral, but the first implementation remain See: +- [Public beta site](docs/index.md) - [Mac Installation](docs/INSTALL_MAC.md) - [iOS Installation](docs/INSTALL_IOS.md) - [Cloudflare Setup](docs/CLOUDFLARE_SETUP.md) +- [FAQ](docs/FAQ.md) +- [Privacy](docs/PRIVACY.md) +- [Support](docs/SUPPORT.md) +- [Changelog](docs/CHANGELOG.md) +- [Screenshot Plan](docs/SCREENSHOTS.md) - [Security](docs/SECURITY.md) - [Architecture](docs/ARCHITECTURE.md) - [Troubleshooting](docs/TROUBLESHOOTING.md) @@ -73,8 +83,8 @@ See: ## Screenshots -Screenshots will be added before the first public release. +Public-safe screenshots are being prepared for the beta. The screenshot checklist is tracked in [docs/SCREENSHOTS.md](docs/SCREENSHOTS.md); no private account names, hosts, tokens, file paths, or personal screenshots should be committed. ## 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. +CodePilot is being prepared for a TestFlight-first iOS beta and GitHub/source distribution for the Mac app, gateway, and docs. 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/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift b/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift index 618f52b0..eb7283d0 100644 --- a/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift +++ b/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift @@ -28,9 +28,9 @@ final class RemoteDesktopCoordinatorTests: XCTestCase { let pending = try coordinator.beginPairing( deviceID: "phone-1", - name: "Tony's iPhone", + name: "Beta iPhone", publicKeyRawRepresentation: publicKey, - macName: "Mac mini" + macName: "Mac host" ) XCTAssertEqual(coordinator.snapshot.pendingPairing?.deviceID, "phone-1") @@ -41,9 +41,9 @@ final class RemoteDesktopCoordinatorTests: XCTestCase { _ = try coordinator.beginPairing( deviceID: "phone-1", - name: "Tony's iPhone", + name: "Beta iPhone", publicKeyRawRepresentation: publicKey, - macName: "Mac mini" + macName: "Mac host" ) try coordinator.approvePendingPairing() @@ -69,9 +69,9 @@ final class RemoteDesktopCoordinatorTests: XCTestCase { let key = P256.Signing.PrivateKey() let challenge = coordinator.pairingStore.issueChallenge( deviceID: "phone-1", - name: "Tony's iPhone", + name: "Beta iPhone", publicKeyRawRepresentation: key.publicKey.rawRepresentation, - macName: "Mac mini" + macName: "Mac host" ) let approval = try coordinator.pairingStore.verifyChallenge( challenge, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 00000000..cfa8ac66 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## Unreleased + +### Public Beta Preparation + +- Added public beta docs for GitHub Pages, FAQ, privacy, support, screenshots, and changelog. +- Clarified that CodePilot currently targets Codex CLI users and remains beta-focused. +- Documented the Mac, gateway, iPhone, Cloudflare, and troubleshooting paths from a public user perspective. +- Added guidance for keeping screenshots, logs, support requests, and public issues free of private names, hosts, tokens, and local paths. + +### Recent Product Work + +- Added Cloudflare setup wizard docs and automation. +- Added gateway health, setup, and recovery guidance. +- Added remote desktop, localhost URL opening, file preview, and gateway-backed iPhone control improvements. diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 00000000..cc838cbe --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,33 @@ +# FAQ + +## Who is CodePilot for? + +CodePilot is for AI coding users who already run Codex CLI on a Mac and want an iPhone companion for visibility, file uploads, turn control, usage status, and account switching. + +## Is CodePilot ready for production teams? + +No. The current release track is a public beta. Use it on a trusted Mac, expect rough edges, and review the security and privacy docs before exposing the gateway outside your local network. + +## Does CodePilot replace Codex CLI? + +No. CodePilot uses Codex on your Mac. The iPhone app talks to the CodePilot gateway, and the gateway uses the active Codex setup on that Mac. + +## Does it support Claude Code or other coding agents? + +Not yet. The name is provider-neutral, but the first beta is Codex-focused. Future provider work should wait until the Codex setup, gateway, and support path are stable. + +## Do I need Cloudflare? + +Only for access away from your local network. Same-network iPhone access can use a local gateway URL. Remote access should use HTTPS and a private gateway token. + +## Does CodePilot send my Codex credentials to a hosted service? + +No hosted CodePilot service is required for the current beta. Credentials stay on the Mac unless you choose to expose the local gateway through infrastructure you control, such as Cloudflare Tunnel. + +## What should I avoid posting in issues? + +Do not post auth files, gateway tokens, private hostnames, personal account names, local file paths, private screenshots, Apple identifiers, or logs that contain any of those values. + +## Is commercial use allowed? + +Not without a separate written license. See the repository license, commercial license, and notice files. diff --git a/docs/INSTALL_IOS.md b/docs/INSTALL_IOS.md index 3d4ba79d..55f84e9e 100644 --- a/docs/INSTALL_IOS.md +++ b/docs/INSTALL_IOS.md @@ -9,6 +9,12 @@ CodePilot iOS connects to a Mac running the CodePilot gateway. - The gateway bearer token from the Mac. - Optional: Cloudflare Tunnel for remote access outside the local network. +## Beta Installation + +TestFlight is the intended iPhone distribution path during the public beta. Install the beta build from the TestFlight invite shared by the maintainers, then connect it to your own Mac gateway. + +Developers building from source can open the Xcode project under `ios/CodexPhone/` and build the `CodexPhone` scheme for a simulator or device configured with their own signing setup. + ## Connection On first launch: diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 00000000..2f704e72 --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,39 @@ +# Privacy + +CodePilot is designed around a Mac you control. In the current beta there is no hosted CodePilot account service. + +## Data On The Mac + +The Mac app and gateway use local Codex and CodePilot state, including auth files, saved account profiles, usage metadata, uploaded files, and gateway configuration. Treat the Mac as trusted infrastructure. + +Sensitive local files include: + +- `~/.codex/auth.json` +- `~/.codex-account-switcher/accounts/*/auth.json` +- `~/.codex-account-switcher/phone-gateway-token` +- `~/.codex-account-switcher/phone-gateway.env` +- `~/.codex-account-switcher/phone-uploads/` + +Do not commit these files or include their contents in screenshots, logs, issues, or support requests. + +## iPhone App + +The iPhone app stores the gateway URL and token locally on the device so it can reconnect to your Mac gateway. The app talks to the gateway you configure; it does not need a hosted CodePilot account for the current beta. + +## Gateway And Remote Access + +The gateway exposes local Codex-related actions to trusted clients. It must require the gateway token. If you use Cloudflare Tunnel, Cloudflare becomes part of your network path and its own terms and privacy practices apply. + +Do not publish gateway URLs, bearer tokens, private hostnames, or screenshots showing connection details. + +## Diagnostics And Support + +Before sharing logs or screenshots, remove: + +- Account names and email addresses. +- Gateway URLs and hostnames. +- Bearer tokens and auth file contents. +- Local file paths and uploaded file names. +- Apple team identifiers or signing details. + +This privacy copy is intended for the beta docs and should be reviewed before any App Store, paid, or broader public release. diff --git a/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md b/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md index 58362bfe..596364ba 100644 --- a/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md +++ b/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md @@ -323,7 +323,7 @@ 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. +Wait until `current.state == "complete"`, then verify the manifest and IPA from the configured OTA domain return HTTP 200. - [ ] **Step 8: Commit** @@ -497,8 +497,8 @@ Expected: `current.state` becomes `complete`. Run: ```sh -curl -I https://ota.sprenkeling.co/codexphone/manifest.plist -curl -I https://ota.sprenkeling.co/codexphone/CodePilot.ipa +curl -I https://ota.example.com/codexphone/manifest.plist +curl -I https://ota.example.com/codexphone/CodePilot.ipa ``` Expected: HTTP 200 or the exact current generated manifest/IPA paths return HTTP 200. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 8ee9ae91..ebe7c69b 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -9,4 +9,6 @@ - [ ] 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 +- [ ] Public screenshots use sanitized demo data only +- [ ] Privacy, support, FAQ, and README copy reviewed for beta positioning - [ ] `.superpowers/` scratch files are not committed diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md new file mode 100644 index 00000000..9216bfd8 --- /dev/null +++ b/docs/SCREENSHOTS.md @@ -0,0 +1,25 @@ +# Screenshot Plan + +No public screenshots are committed yet. + +Use this checklist before adding screenshots to the repository, GitHub Pages, TestFlight, App Store metadata, README, or support docs: + +- Use demo account names only, such as `Work`, `Personal`, or `Demo`. +- Use example hostnames only, such as `codepilot.example.com`. +- Hide gateway bearer tokens and auth details. +- Hide local file paths unless the path is a generic example. +- Hide private thread names, prompts, uploads, and repository names. +- Hide Apple IDs, team IDs, bundle signing details, and TestFlight account details. +- Prefer clean beta flows: setup checklist, gateway status, iPhone connection, thread list, file upload, and remote desktop permission state. + +Recommended beta screenshot set: + +- Mac menu bar status with demo account usage. +- Mac setup checklist showing ready and optional states. +- Cloudflare setup wizard using an example hostname. +- iPhone connection wizard for same-network and Cloudflare modes. +- iPhone thread list with demo thread names. +- File upload confirmation using non-private sample files. +- Remote desktop permission screen without a live private desktop. + +Create real screenshots only from sanitized demo data. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 07f7b718..a40b6289 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -34,3 +34,5 @@ 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. + +See [Privacy](PRIVACY.md) and [Support](SUPPORT.md) for public beta reporting guidance. diff --git a/docs/SUPPORT.md b/docs/SUPPORT.md new file mode 100644 index 00000000..3905a7ac --- /dev/null +++ b/docs/SUPPORT.md @@ -0,0 +1,40 @@ +# Support + +CodePilot beta support is handled through public repository issues and pull requests. + +## Before Opening An Issue + +Check: + +- [Mac installation](INSTALL_MAC.md) +- [iOS installation](INSTALL_IOS.md) +- [Cloudflare setup](CLOUDFLARE_SETUP.md) +- [Troubleshooting](TROUBLESHOOTING.md) +- [Security](SECURITY.md) +- [FAQ](FAQ.md) + +## Helpful Issue Details + +Include: + +- Mac app, gateway, or iOS app area affected. +- What you expected to happen. +- What happened instead. +- Whether the iPhone is on the same network or using Cloudflare. +- The visible recovery message, if one appears. +- Sanitized logs with private values removed. + +## Do Not Share + +Do not include: + +- Codex auth files. +- Gateway bearer tokens. +- Private hostnames or Cloudflare tunnel URLs. +- Personal account names or email addresses. +- Apple account, team, signing, or TestFlight identifiers. +- Screenshots that show private files, paths, accounts, tokens, or hosts. + +## Security Reports + +For issues involving exposed credentials, unsafe gateway access, remote desktop bypass, or uploaded private files, avoid public details. Open a minimal issue that says a security report is available and coordinate disclosure with the maintainers. diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 00000000..16d0d1d3 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,4 @@ +title: CodePilot +description: Public beta docs for CodePilot, a Mac and iPhone companion for Codex CLI users. +markdown: kramdown +theme: minima diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..02ba0f0e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,31 @@ +# CodePilot Public Beta + +CodePilot is a Mac menu bar app, local gateway, and iPhone companion for Codex CLI users who want to monitor and steer coding-agent sessions from a Mac they control. + +The beta is focused on practical workflows for AI coding users: + +- See active Codex usage and account status from the Mac and iPhone. +- Switch saved Codex account profiles after active turns finish. +- Connect the iPhone app to the Mac gateway on the same network or through a user-owned Cloudflare Tunnel. +- Upload files, inspect threads, steer or stop turns where supported, and receive turn-finished notifications. +- Use remote desktop features only after pairing and macOS permission checks. + +CodePilot is source-available for noncommercial use. It is not OSI open source. See the repository license files before redistributing or using it commercially. + +## Start Here + +- [Mac installation](INSTALL_MAC.md) +- [iOS installation](INSTALL_IOS.md) +- [Cloudflare remote access](CLOUDFLARE_SETUP.md) +- [Troubleshooting](TROUBLESHOOTING.md) +- [FAQ](FAQ.md) +- [Privacy](PRIVACY.md) +- [Support](SUPPORT.md) +- [Changelog](CHANGELOG.md) +- [Screenshot plan](SCREENSHOTS.md) + +## Beta Limits + +CodePilot currently targets Codex CLI. Future provider support is not part of the first beta promise. + +Do not share screenshots, logs, gateway URLs, tokens, account names, hostnames, or local file paths in public issues unless they have been sanitized. diff --git a/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md b/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md index 27bb2647..661be3e8 100644 --- a/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md +++ b/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md @@ -632,11 +632,11 @@ Verify pairing approval, Face ID session start, Wi-Fi direct path, cellular TURN Run: ```bash -cd /Users/homeserver/Developer/de-kleine-eekhoorn/ios +cd /Users/example/CodePilot/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. +Expected: status reaches `complete`; output reports bundle ID `legacy.bundle.identifier`; both public manifest and IPA return HTTP 200; the OTA install updates the configured CodePilot app rather than creating another instance. - [ ] **Step 7: Commit** 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 index 506d14f1..96aeea4c 100644 --- a/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md +++ b/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md @@ -15,14 +15,14 @@ - 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. +- Agents should ping the maintainer 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: +Ping the maintainer 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. @@ -31,7 +31,7 @@ Ping Tony only for: - 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: +Do not ping the maintainer for: - Routine dependency updates that pass tests. - GitHub issue triage. @@ -146,7 +146,7 @@ Do not ping Tony for: - 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. +**Important constraint:** The agent can prepare the Reddit account plan, profile text, and posts, but the maintainer must create or approve any Reddit account and final posts. Creating accounts and posting must comply with Reddit rules and subreddit rules. **Outputs:** @@ -216,7 +216,7 @@ Do not ping Tony for: 4. **Prepare community launch** - Community Growth Agent drafts Reddit account profile and launch posts. - - Tony approves account/posting strategy. + - the maintainer approves account/posting strategy. - Agent schedules staged, rule-compliant posts. 5. **Start beta loop** @@ -229,7 +229,7 @@ Do not ping Tony for: - 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. + - the maintainer approves pricing, legal, and App Store submission. ## Proposed Recurring Automations diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift index 98306811..11262566 100644 --- a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -48,28 +48,28 @@ final class RemoteDesktopTests: XCTestCase { } func testRemoteFilePathAcceptsCustomPreviewURL() throws { - let url = try XCTUnwrap(remoteFilePreviewURL(path: "/Users/homeserver/Developer/CodePilot/README.md")) + let url = try XCTUnwrap(remoteFilePreviewURL(path: "/Users/example/CodePilot/README.md")) - XCTAssertEqual(remoteFilePath(from: url), "/Users/homeserver/Developer/CodePilot/README.md") + XCTAssertEqual(remoteFilePath(from: url), "/Users/example/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")) + let url = try XCTUnwrap(URL(string: "/Users/example/CodePilot/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" + "/Users/example/CodePilot/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") + let url = URL(fileURLWithPath: "/Users/example/CodePilot/Sources/App.swift:42") - XCTAssertEqual(remoteFilePath(from: url), "/Users/homeserver/Developer/CodePilot/Sources/App.swift") + XCTAssertEqual(remoteFilePath(from: url), "/Users/example/CodePilot/Sources/App.swift") } func testRemoteFilePathRejectsWebURLs() throws { - let url = try XCTUnwrap(URL(string: "https://example.com/Users/homeserver/README.md")) + let url = try XCTUnwrap(URL(string: "https://example.com/Users/example/README.md")) XCTAssertNil(remoteFilePath(from: url)) } diff --git a/ops/agents/escalations/presence-maintenance.md b/ops/agents/escalations/presence-maintenance.md new file mode 100644 index 00000000..d66ccb84 --- /dev/null +++ b/ops/agents/escalations/presence-maintenance.md @@ -0,0 +1,8 @@ +# Presence Maintenance Escalation + +Approval needed before public publishing: + +- Confirm beta privacy copy in `docs/PRIVACY.md`. +- Confirm public positioning in `README.md`, `docs/index.md`, and `docs/FAQ.md`. +- Enable or confirm GitHub Pages source for the repository, preferably the `docs/` folder. +- Approve sanitized screenshot capture before screenshots are committed or used in App Store/TestFlight metadata. diff --git a/ops/agents/prompts/release-readiness.md b/ops/agents/prompts/release-readiness.md index 260d8b22..7df6dc9e 100644 --- a/ops/agents/prompts/release-readiness.md +++ b/ops/agents/prompts/release-readiness.md @@ -7,7 +7,7 @@ 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. +- Do not submit App Store review or alter pricing/legal metadata without the maintainer. - If intervention is needed, write a concise escalation note to `ops/agents/escalations/release-readiness.md`. Report: diff --git a/scripts/codepilot-agent-escalate.sh b/scripts/codepilot-agent-escalate.sh index ca969283..ce607a70 100755 --- a/scripts/codepilot-agent-escalate.sh +++ b/scripts/codepilot-agent-escalate.sh @@ -1,7 +1,7 @@ #!/usr/bin/env zsh set -euo pipefail -ROOT="${CODEPILOT_REPO_ROOT:-/Users/homeserver/Developer/CodexAccountSwitcher}" +ROOT="${CODEPILOT_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" STATE_DIR="${CODEPILOT_AGENT_STATE_DIR:-$HOME/.codex-account-switcher/agents}" THREAD_ID_FILE="$STATE_DIR/thread-id" CODEX_BIN="${CODEPILOT_CODEX_BIN:-codex}" diff --git a/scripts/codepilot-agent-runner.sh b/scripts/codepilot-agent-runner.sh index cacde95e..289a231e 100755 --- a/scripts/codepilot-agent-runner.sh +++ b/scripts/codepilot-agent-runner.sh @@ -1,7 +1,7 @@ #!/usr/bin/env zsh set -euo pipefail -ROOT="${CODEPILOT_REPO_ROOT:-/Users/homeserver/Developer/CodexAccountSwitcher}" +ROOT="${CODEPILOT_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" JOB="${1:-}" if [[ -z "$JOB" ]]; then diff --git a/scripts/install-codepilot-local-agents.sh b/scripts/install-codepilot-local-agents.sh index 273c47a3..006485cc 100755 --- a/scripts/install-codepilot-local-agents.sh +++ b/scripts/install-codepilot-local-agents.sh @@ -1,7 +1,7 @@ #!/usr/bin/env zsh set -euo pipefail -ROOT="${CODEPILOT_REPO_ROOT:-/Users/homeserver/Developer/CodexAccountSwitcher}" +ROOT="${CODEPILOT_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" PLIST_DIR="$HOME/Library/LaunchAgents" RUNNER="$ROOT/scripts/codepilot-agent-runner.sh" diff --git a/scripts/privacy-audit.sh b/scripts/privacy-audit.sh index 679a343f..087d1ecc 100755 --- a/scripts/privacy-audit.sh +++ b/scripts/privacy-audit.sh @@ -36,7 +36,14 @@ print("|".join(bytes.fromhex(entry).decode("utf-8") for entry in entries)) PY )" -if git grep -n -I -E "$pattern" -- "$(git ls-files)"; then +tracked_files=("${(@f)$(git ls-files)}") +audit_files=() +for file in "${tracked_files[@]}"; do + [[ "$file" == "scripts/privacy-audit.sh" ]] && continue + audit_files+=("$file") +done + +if git grep -n -I -E "$pattern" -- "${audit_files[@]}"; then echo "privacy audit failed: tracked files contain private identifiers" >&2 exit 1 fi @@ -44,7 +51,7 @@ fi secret_patterns=( 'ghp_[A-Za-z0-9_]+' 'github_pat_[A-Za-z0-9_]+' - 'sk-[A-Za-z0-9]+' + 'sk-[A-Za-z0-9]{20,}' '-----BEGIN (RSA|OPENSSH|PRIVATE) KEY' 'Bearer [A-Za-z0-9._-]{20,}' 'client_secret' @@ -53,7 +60,7 @@ secret_patterns=( secret_pattern="$(IFS='|'; echo "${secret_patterns[*]}")" -if git grep -n -I -E "$secret_pattern" -- "$(git ls-files)"; then +if git grep -n -I -E "$secret_pattern" -- "${audit_files[@]}"; then echo "privacy audit failed: tracked files contain secret-looking material" >&2 exit 1 fi From 221ee9455c2eea7fc732b0508b82ae1d8001e243 Mon Sep 17 00:00:00 2001 From: codepilotios Date: Fri, 3 Jul 2026 13:15:59 +0200 Subject: [PATCH 04/57] [codex] Fix setup readiness triage issues (#10) * fix: launch trycloudflare setup in terminal * fix: show setup readiness for remote desktop * chore: record issue triage escalations * chore: record issue triage follow-up * chore: record issue triage follow-up check * chore: record issue triage live check * chore: record issue triage autonomy pass * chore: record issue triage follow-up pass * chore: record issue triage follow-up pass * chore: record issue triage launch pass * chore: record issue triage autonomy pass --------- Co-authored-by: CodePilot --- Sources/CodexAccountSwitcher/main.swift | 94 ++++++++++++++++++- .../CloudflareSetupTests.swift | 10 ++ .../SetupStatusTests.swift | 9 ++ ops/agents/escalations/issue-triage.md | 7 ++ ops/agents/triage/2026-07-01-issue-triage.md | 43 +++++++++ ops/agents/triage/2026-07-02-issue-triage.md | 57 +++++++++++ ops/agents/triage/2026-07-03-issue-triage.md | 64 +++++++++++++ 7 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 ops/agents/escalations/issue-triage.md create mode 100644 ops/agents/triage/2026-07-01-issue-triage.md create mode 100644 ops/agents/triage/2026-07-02-issue-triage.md create mode 100644 ops/agents/triage/2026-07-03-issue-triage.md diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index cdf1059f..eec8eb05 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -1,4 +1,5 @@ import AppKit +import ApplicationServices import CryptoKit import Darwin import Foundation @@ -2342,6 +2343,8 @@ private struct CodePilotSetupStatus { cloudflareRequirement = .cloudflareNeedsConfiguration cloudflareDetail = "cloudflared is installed; set up a tunnel for remote access." } + let permissions = SystemRemoteDesktopPermissions() + let notificationsConfigured = apnsConfigured() return CodePilotSetupStatus(rows: [ CodePilotSetupRow( title: "Codex CLI", @@ -2372,6 +2375,27 @@ private struct CodePilotSetupStatus { title: "Cloudflare", requirement: cloudflareRequirement, detail: cloudflareDetail + ), + CodePilotSetupRow( + title: "Screen Recording", + requirement: permissions.screenRecordingGranted ? .screenRecordingReady : .screenRecordingMissing, + detail: permissions.screenRecordingGranted + ? "Remote Desktop viewing is allowed." + : "Allow CodePilot in System Settings to view the Mac screen." + ), + CodePilotSetupRow( + title: "Accessibility", + requirement: permissions.accessibilityGranted ? .accessibilityReady : .accessibilityMissing, + detail: permissions.accessibilityGranted + ? "Remote Desktop control is allowed." + : "Allow CodePilot in System Settings to control the Mac." + ), + CodePilotSetupRow( + title: "Notifications", + requirement: notificationsConfigured ? .notificationsReady : .notificationsOptional, + detail: notificationsConfigured + ? "APNs credentials are configured for turn-finished alerts." + : "Configure APNs only if background iPhone alerts are needed." ) ]) } @@ -2454,6 +2478,22 @@ private struct CodePilotSetupStatus { guard let data = try? Data(contentsOf: path) else { return nil } return try? JSONDecoder().decode(CodePilotCloudflareMetadata.self, from: data) } + + private static func apnsConfigured( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: NSString(string: $0).expandingTildeInPath) } + ) -> Bool { + let certPath = environment["CODEX_PHONE_APNS_CERT_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let certKeyPath = environment["CODEX_PHONE_APNS_CERT_KEY_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !certPath.isEmpty, !certKeyPath.isEmpty, fileExists(certPath), fileExists(certKeyPath) { + return true + } + + let teamID = environment["CODEX_PHONE_APNS_TEAM_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let keyID = environment["CODEX_PHONE_APNS_KEY_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let keyPath = environment["CODEX_PHONE_APNS_KEY_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !teamID.isEmpty && !keyID.isEmpty && !keyPath.isEmpty && fileExists(keyPath) + } } struct CodePilotSetupRow: Equatable { @@ -2490,6 +2530,16 @@ enum CodePilotCloudflareErrorMapper { } } +enum CodePilotTryCloudflareCommand { + static func terminalCommand(scriptPath: String, currentDirectory: String) -> String { + "cd \(shellQuoted(currentDirectory)) && \(shellQuoted(scriptPath)) start-trycloudflare" + } + + private static func shellQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } +} + enum CodePilotSetupRequirement: Equatable { case codexCLIInstalled case codexCLIMissing @@ -2506,13 +2556,16 @@ enum CodePilotSetupRequirement: Equatable { case cloudflareOptional case cloudflareMissing case cloudflareNeedsConfiguration + case screenRecordingReady case screenRecordingMissing + case accessibilityReady case accessibilityMissing + case notificationsReady case notificationsOptional var statusLabel: String { switch self { - case .codexCLIInstalled, .codexSignedIn, .profilesCreated, .gatewayTokenPresent, .gatewayRunning, .cloudflareReady: + case .codexCLIInstalled, .codexSignedIn, .profilesCreated, .gatewayTokenPresent, .gatewayRunning, .cloudflareReady, .screenRecordingReady, .accessibilityReady, .notificationsReady: return "Ready" case .cloudflareNeedsConfiguration: return "Needs setup" @@ -2598,6 +2651,14 @@ private final class CodePilotSetupWindowController: NSWindowController { button("Open Cloudflare Guide", #selector(openCloudflareGuide)) ] )) + root.addArrangedSubview(section( + title: "Remote Desktop & Notifications", + buttons: [ + button("Request Screen Recording", #selector(requestScreenRecordingPermission)), + button("Request Accessibility", #selector(requestAccessibilityPermission)), + button("Open iOS Notification Guide", #selector(openIOSNotificationGuide)) + ] + )) let refresh = button("Refresh Status", #selector(refreshStatusAction)) root.addArrangedSubview(refresh) @@ -2706,6 +2767,25 @@ private final class CodePilotSetupWindowController: NSWindowController { } } + @objc private func requestScreenRecordingPermission() { + _ = CGRequestScreenCaptureAccess() + outputLabel.stringValue = "Screen Recording prompt requested. Reopen CodePilot after changing System Settings." + refreshStatus() + } + + @objc private func requestAccessibilityPermission() { + let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + outputLabel.stringValue = "Accessibility prompt requested. Refresh after changing System Settings." + refreshStatus() + } + + @objc private func openIOSNotificationGuide() { + let sourceURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("docs/INSTALL_IOS.md") + NSWorkspace.shared.open(sourceURL) + } + private func runBundledScript(named name: String, arguments: [String] = [], force: Bool = false) { let candidates = [ Bundle.main.resourceURL?.appendingPathComponent("scripts/\(name)"), @@ -2879,7 +2959,17 @@ private final class CodePilotCloudflareWizardController: NSWindowController { } @objc private func startTemporary() { - runCloudflareStep(["start-trycloudflare"], successMessage: "Temporary Cloudflare URL started. Use this only for testing.") + guard let script = scriptURL() else { + outputLabel.stringValue = "Could not find setup-cloudflare-remote-access.sh." + return + } + let command = CodePilotTryCloudflareCommand.terminalCommand( + scriptPath: script.path, + currentDirectory: FileManager.default.currentDirectoryPath + ) + runInTerminal(command) + outputLabel.stringValue = "Temporary Cloudflare URL is starting in Terminal. Copy the trycloudflare.com URL from the Terminal output when it appears." + detailsLabel.stringValue = command } @objc private func openCloudflareDashboard() { diff --git a/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift b/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift index 8e56e823..c1510bcd 100644 --- a/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift +++ b/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift @@ -35,4 +35,14 @@ final class CloudflareSetupTests: XCTestCase { "cloudflared is missing. Install it from the Cloudflare setup step before continuing." ) } + + func testTryCloudflareTerminalCommandUsesLongRunningSubcommand() { + let command = CodePilotTryCloudflareCommand.terminalCommand( + scriptPath: "/tmp/Code Pilot/scripts/setup-cloudflare-remote-access.sh", + currentDirectory: "/tmp/Code Pilot" + ) + + XCTAssertTrue(command.contains("cd '/tmp/Code Pilot'")) + XCTAssertTrue(command.contains("'/tmp/Code Pilot/scripts/setup-cloudflare-remote-access.sh' start-trycloudflare")) + } } diff --git a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift index a7068d0f..f442b089 100644 --- a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift +++ b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift @@ -14,4 +14,13 @@ final class SetupStatusTests: XCTestCase { XCTAssertEqual(CodePilotSetupRequirement.cloudflareNeedsConfiguration.statusLabel, "Needs setup") XCTAssertEqual(CodePilotSetupRequirement.cloudflareOptional.statusLabel, "Optional") } + + func testRemoteDesktopAndNotificationStatusLabelsAreUserFacing() { + XCTAssertEqual(CodePilotSetupRequirement.screenRecordingReady.statusLabel, "Ready") + XCTAssertEqual(CodePilotSetupRequirement.screenRecordingMissing.statusLabel, "Missing") + XCTAssertEqual(CodePilotSetupRequirement.accessibilityReady.statusLabel, "Ready") + XCTAssertEqual(CodePilotSetupRequirement.accessibilityMissing.statusLabel, "Missing") + XCTAssertEqual(CodePilotSetupRequirement.notificationsReady.statusLabel, "Ready") + XCTAssertEqual(CodePilotSetupRequirement.notificationsOptional.statusLabel, "Optional") + } } diff --git a/ops/agents/escalations/issue-triage.md b/ops/agents/escalations/issue-triage.md new file mode 100644 index 00000000..535fc4f1 --- /dev/null +++ b/ops/agents/escalations/issue-triage.md @@ -0,0 +1,7 @@ +# Issue Triage Escalations + +Date: 2026-07-03 + +- #2 Same Network setup path: needs a product/security decision to either support LAN binding explicitly or disable the iOS Same Network path until it is safe and documented. +- #3 Remote Desktop pairing approval: needs maintainer decision on the trust model before public beta. Either require explicit Mac approval in the pairing flow or remove unused pairing-code UI and document bearer-token possession as the trust boundary. +- #8 Public screenshot set: maintainer approval is required before screenshots are committed, used in README/GitHub Pages, or reused in TestFlight/App Store metadata. diff --git a/ops/agents/triage/2026-07-01-issue-triage.md b/ops/agents/triage/2026-07-01-issue-triage.md new file mode 100644 index 00000000..4d5ea14c --- /dev/null +++ b/ops/agents/triage/2026-07-01-issue-triage.md @@ -0,0 +1,43 @@ +# CodePilot Issue Triage - 2026-07-01 + +Remote write policy: read-only GitHub inspection only. No remote comments, labels, pushes, PRs, or issue edits were made. + +## Reviewed Issues + +- #1 Add permissions and notification readiness to the Mac setup checklist + - Proposed labels: `setup`, `remote-desktop`, `mac`, `notifications`, `severity: low`. + - Triage: confirmed from existing issue body/comment that the setup checklist still needed visible Screen Recording, Accessibility, and notification/APNs readiness rows. + - Local fix: added setup checklist rows for Screen Recording, Accessibility, and Notifications; added setup-window recovery buttons for the macOS permission prompts and iOS notification guide; added status-label coverage for the new Ready/Optional/Missing states. +- #2 Support or disable the iOS Same Network setup path + - Proposed labels: `setup`, `ios`, `gateway`, `severity: medium`. + - Triage: product/security decision required because default gateway binding is loopback-only while iOS exposes Same Network setup. Already escalated locally. +- #3 Require clear Remote Desktop pairing approval in setup + - Proposed labels: `setup`, `remote-desktop`, `mac`, `ios`, `severity: high`. + - Triage: trust-model decision required before public beta. Already escalated locally. +- #4 TryCloudflare setup step waits forever for the tunnel process + - Proposed labels: `bug`, `setup`, `mac`, `cloudflare`, `severity: medium`. + - Triage: reproduced by code inspection. `startTemporary()` previously called `runCloudflareStep(["start-trycloudflare"])`, which uses `runProcess` and waits for process exit; `cloudflared tunnel --url` is long-running. + - Local fix: changed the TryCloudflare action to open the long-running command in Terminal so stdout can stream and the temporary URL appears without the setup sheet waiting for process exit. +- #5 Permanent Cloudflare setup does not safely reuse existing tunnels + - Proposed labels: `bug`, `setup`, `mac`, `cloudflare`, `severity: medium`. + - Triage: already has a prior local/draft fix referenced by the existing issue comment. No duplicate fix made. +- #8 Prepare sanitized public screenshot set + - Proposed labels: `documentation`, `severity: low`. + - Triage: maintainer approval required before screenshots are captured, committed, or reused publicly. Already escalated locally. + +## Local Validation + +- `swift test --filter CloudflareSetupTests/testTryCloudflareTerminalCommandUsesLongRunningSubcommand` failed before implementation because `CodePilotTryCloudflareCommand` was missing. +- `swift test --filter CloudflareSetupTests` passed after implementation. +- `swift test --filter SetupStatusTests` passed after adding setup readiness rows for #1. +- `swift test` passed after the #1 setup checklist changes. + +## Local Branches And Commits + +- Branch: `agent/setup-readiness-fixes`. +- Existing local commit: `1d46890 fix: launch trycloudflare setup in terminal`. +- New local commit: `fix: show setup readiness for remote desktop`. + +## Escalations + +No new escalation beyond the existing `ops/agents/escalations/issue-triage.md` entries for #2, #3, and #8. diff --git a/ops/agents/triage/2026-07-02-issue-triage.md b/ops/agents/triage/2026-07-02-issue-triage.md new file mode 100644 index 00000000..48a7740b --- /dev/null +++ b/ops/agents/triage/2026-07-02-issue-triage.md @@ -0,0 +1,57 @@ +# CodePilot Issue Triage - 2026-07-02 + +Remote write policy: public GitHub writes are allowed after privacy audit when they directly advance launch readiness. Privacy audit passed before inspecting the existing draft PR state. No new remote issue comments or label edits were needed in this pass. + +## Follow-Up Pass + +- Checked open GitHub issues on 2026-07-02 after the prior triage run. +- No new open issues were found. +- Existing issue labels still match the proposed component and severity triage below. +- Draft PR #10 remains open, clean, and attached to `agent/setup-readiness-fixes`. +- Later 2026-07-02 pass: rechecked the open issue queue and confirmed no issues newer than #8. +- Privacy audit passed before this local note update. +- Draft PR #10 still has passing `Test and Audit` status and `CLEAN` merge state. +- Launch autonomy pass at 2026-07-02T19:00:41Z: rechecked open issues #1, #2, #3, #4, and #8. +- No new open GitHub issues were found. +- Privacy audit passed before this public-write-eligible local note update. +- Draft PR #10 remains open, draft, `CLEAN`, and its `Test and Audit` check is passing. +- Launch autonomy pass at 2026-07-02T21:17:05Z: rechecked the open issue queue and issue details for #1, #2, #3, #4, and #8. +- No new open GitHub issues were found, and no existing issue bodies or comments changed since the prior triage state. +- Privacy audit passed before this public-write-eligible local note update. +- Draft PR #10 remains open, draft, `CLEAN`, and its `Test and Audit` check is passing. +- No new GitHub comments, labels, branches, or PRs were needed in this pass. + +## Reviewed Issues + +- #1 Add permissions and notification readiness to the Mac setup checklist + - Proposed labels: `setup`, `remote-desktop`, `mac`, `notifications`, `severity: low`. + - Triage: actionable low-risk setup visibility issue. + - Status: addressed by draft PR #10 on branch `agent/setup-readiness-fixes`. +- #2 Support or disable the iOS Same Network setup path + - Proposed labels: `setup`, `ios`, `gateway`, `severity: medium`. + - Triage: still blocked on product/security decision for LAN binding versus disabling Same Network. + - Status: escalation remains in `ops/agents/escalations/issue-triage.md`. +- #3 Require clear Remote Desktop pairing approval in setup + - Proposed labels: `setup`, `remote-desktop`, `mac`, `ios`, `severity: high`. + - Triage: still blocked on Remote Desktop trust-model decision before public beta. + - Status: escalation remains in `ops/agents/escalations/issue-triage.md`. +- #4 TryCloudflare setup step waits forever for the tunnel process + - Proposed labels: `bug`, `setup`, `mac`, `cloudflare`, `severity: medium`. + - Triage: actionable setup bug reproduced by code inspection. + - Status: addressed by draft PR #10 on branch `agent/setup-readiness-fixes`. +- #8 Prepare sanitized public screenshot set + - Proposed labels: `documentation`, `severity: low`. + - Triage: screenshot capture and public use require maintainer approval. + - Status: escalation remains in `ops/agents/escalations/issue-triage.md`. + +## Local Branches And Pull Requests + +- Existing branch: `agent/setup-readiness-fixes`. +- Existing draft PR: #10, `[codex] Fix setup readiness triage issues`. +- Current branch includes local triage notes and escalations for the unresolved decision-gated issues. + +## Blockers + +- Maintainer decision needed for #2 Same Network behavior. +- Maintainer decision needed for #3 Remote Desktop pairing trust model. +- Maintainer approval needed before #8 screenshots are captured, committed, or used publicly. diff --git a/ops/agents/triage/2026-07-03-issue-triage.md b/ops/agents/triage/2026-07-03-issue-triage.md new file mode 100644 index 00000000..4245d2ee --- /dev/null +++ b/ops/agents/triage/2026-07-03-issue-triage.md @@ -0,0 +1,64 @@ +# CodePilot Issue Triage - 2026-07-03 + +Remote write policy: public GitHub writes are allowed after privacy audit when they directly advance launch readiness. This pass did not need new remote issue comments or label edits. + +## Follow-Up Pass + +- Rechecked the open GitHub issue queue for CodePilot. +- No new open issues were found after the prior 2026-07-02 triage pass. +- Rechecked open issues #1, #2, #3, #4, and #8. +- Existing labels still match the proposed component and severity triage. +- Draft PR #10 remains open, draft, `CLEAN`, and attached to `agent/setup-readiness-fixes`. +- Draft PR #10 has a successful `Test and Audit` check from CI. + +## Reviewed Issues + +- #1 Add permissions and notification readiness to the Mac setup checklist + - Proposed labels: `setup`, `remote-desktop`, `mac`, `notifications`, `severity: low`. + - Triage: actionable low-risk setup visibility issue. + - Status: addressed by draft PR #10 on branch `agent/setup-readiness-fixes`. +- #2 Support or disable the iOS Same Network setup path + - Proposed labels: `setup`, `ios`, `gateway`, `severity: medium`. + - Triage: still blocked on product/security decision for LAN binding versus disabling Same Network. + - Status: escalation remains in `ops/agents/escalations/issue-triage.md`. +- #3 Require clear Remote Desktop pairing approval in setup + - Proposed labels: `setup`, `remote-desktop`, `mac`, `ios`, `severity: high`. + - Triage: still blocked on Remote Desktop trust-model decision before public beta. + - Status: escalation remains in `ops/agents/escalations/issue-triage.md`. +- #4 TryCloudflare setup step waits forever for the tunnel process + - Proposed labels: `bug`, `setup`, `mac`, `cloudflare`, `severity: medium`. + - Triage: actionable setup bug reproduced by code inspection in the prior pass. + - Status: addressed by draft PR #10 on branch `agent/setup-readiness-fixes`. +- #8 Prepare sanitized public screenshot set + - Proposed labels: `documentation`, `severity: low`. + - Triage: screenshot capture and public use require maintainer approval. + - Status: escalation remains in `ops/agents/escalations/issue-triage.md`. + +## Local Branches And Pull Requests + +- Existing branch: `agent/setup-readiness-fixes`. +- Existing draft PR: #10, `[codex] Fix setup readiness triage issues`. +- No new branch was needed in this pass. + +## Blockers + +- Maintainer decision needed for #2 Same Network behavior. +- Maintainer decision needed for #3 Remote Desktop pairing trust model. +- Maintainer approval needed before #8 screenshots are captured, committed, or used publicly. + +## Launch Autonomy Pass + +- Rechecked the open GitHub issue queue during the July 3 unattended issue-triage run. +- No new open issues were found beyond #1, #2, #3, #4, and #8. +- Proposed component and severity labels remain unchanged for all reviewed issues. +- Draft PR #10 remains open and draft on `agent/setup-readiness-fixes`. +- The latest visible PR #10 CI check, `Test and Audit`, is passing. +- No GitHub issue comments, label edits, screenshot captures, release actions, or external system mutations were needed in this pass. + +## Launch Autonomy Pass - 2026-07-03T08:42:47Z + +- Rechecked the open GitHub issue queue for #1, #2, #3, #4, and #8. +- No new open issues were found, and no issue labels needed changes. +- Draft PR #10 remains the active low-risk fix branch for #1 and #4. +- Privacy audit passed before this local note update and before any public-write-eligible action. +- No new GitHub issue comments, label edits, branches, PRs, screenshots, release actions, or external system mutations were needed. From 1adb19c6818bc8c106cadcd257049dfa8e847864 Mon Sep 17 00:00:00 2001 From: codepilotios Date: Fri, 3 Jul 2026 22:56:37 +0200 Subject: [PATCH 05/57] [codex] Record issue triage status (#11) * chore: record issue triage closure pass * chore: record latest issue triage pass * chore: record issue triage status pass * chore: record latest issue triage pass --------- Co-authored-by: CodePilot --- ops/agents/triage/2026-07-03-issue-triage.md | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/ops/agents/triage/2026-07-03-issue-triage.md b/ops/agents/triage/2026-07-03-issue-triage.md index 4245d2ee..17d630b2 100644 --- a/ops/agents/triage/2026-07-03-issue-triage.md +++ b/ops/agents/triage/2026-07-03-issue-triage.md @@ -62,3 +62,59 @@ Remote write policy: public GitHub writes are allowed after privacy audit when t - Draft PR #10 remains the active low-risk fix branch for #1 and #4. - Privacy audit passed before this local note update and before any public-write-eligible action. - No new GitHub issue comments, label edits, branches, PRs, screenshots, release actions, or external system mutations were needed. + +## Issue Triage Pass - 2026-07-03T12:00Z + +- Rechecked the GitHub issue queue; open issues after this pass are #2, #3, and #8. +- Verified #1 is fixed on `origin/main`: the Mac setup checklist includes Screen Recording, Accessibility, and Notifications readiness rows. +- Ran a privacy audit over the local triage and escalation notes before the public issue update. +- Closed #1 with a public-safe triage comment referencing merged PR #10. +- Confirmed #4 is already closed and PR #10 is merged with a successful `Test and Audit` check. +- Proposed component and severity labels remain unchanged for remaining open issues: + - #2: `setup`, `ios`, `gateway`, `severity: medium`. + - #3: `setup`, `remote-desktop`, `mac`, `ios`, `severity: high`. + - #8: `documentation`, `severity: low`. +- No new low-risk code fix was identified in this pass, so no new app or gateway tests were run. +- No screenshots, release actions, pricing/legal/App Store changes, credential changes, or non-GitHub external mutations were performed. + +## Remaining Blockers - 2026-07-03T12:00Z + +- #2 Same Network setup path still needs a product/security decision: explicitly support LAN binding with security copy, or disable the iOS Same Network path until supported. +- #3 Remote Desktop pairing approval still needs a trust-model decision before public beta. +- #8 Public screenshot set still needs maintainer approval before screenshots are captured, committed, or used publicly. + +## Issue Triage Pass - 2026-07-03T15:26:54Z + +- Rechecked the open GitHub issue queue; open issues remain #2, #3, and #8. +- Reviewed issue bodies and comments for #2, #3, and #8; there have been no new comments since the July 1 triage updates. +- Proposed component and severity labels remain correct: + - #2: `setup`, `ios`, `gateway`, `severity: medium`. + - #3: `setup`, `remote-desktop`, `mac`, `ios`, `severity: high`. + - #8: `documentation`, `severity: low`. +- Ran `scripts/privacy-audit.sh` before this local note update; it passed. +- No label edits, GitHub comments, new branches, draft PRs, screenshots, release actions, credential changes, or non-GitHub external mutations were needed. +- No low-risk code fix was available without a maintainer decision on #2, #3, or #8. + +## Issue Triage Pass - 2026-07-03T17:36:33Z + +- Rechecked the open GitHub issue queue; open issues remain #2, #3, and #8. +- Confirmed there are no open pull requests currently pending issue-triage follow-up. +- Reviewed issue metadata, labels, bodies, and comments for #2, #3, and #8; no issue has changed since the July 1 triage comments. +- Proposed component and severity labels remain correct: + - #2: `setup`, `ios`, `gateway`, `severity: medium`. + - #3: `setup`, `remote-desktop`, `mac`, `ios`, `severity: high`. + - #8: `documentation`, `severity: low`. +- No GitHub comments, label edits, branches, draft PRs, screenshots, release actions, credential changes, or non-GitHub external mutations were needed. +- No low-risk code fix was available without maintainer decisions or approval for #2, #3, or #8. + +## Issue Triage Pass - 2026-07-03T19:51:16Z + +- Rechecked the open GitHub issue queue; open issues remain #2, #3, and #8. +- Reviewed issue metadata, labels, bodies, and comments for #2, #3, and #8; no new issue comments or label changes were present. +- Confirmed draft PR #11 remains open, draft, `CLEAN`, and backed by a successful `Test and Audit` check. +- Proposed component and severity labels remain correct: + - #2: `setup`, `ios`, `gateway`, `severity: medium`. + - #3: `setup`, `remote-desktop`, `mac`, `ios`, `severity: high`. + - #8: `documentation`, `severity: low`. +- No GitHub comments, label edits, new branches, screenshots, release actions, credential changes, or non-GitHub external mutations were needed. +- No low-risk code fix was available without maintainer decisions or approval for #2, #3, or #8. From 06eebc362fd8817ab76f6c39a72b2a636b095338 Mon Sep 17 00:00:00 2001 From: codepilotios Date: Sat, 4 Jul 2026 06:35:32 +0200 Subject: [PATCH 06/57] fix: require approved remote pairing for beta (#14) Co-authored-by: CodePilot --- .../RemoteDesktopCoordinator.swift | 42 +++++++++++++++---- .../RemoteDesktop/RemoteDesktopModels.swift | 7 ++++ .../RemoteDesktopWindowController.swift | 1 + Sources/CodexAccountSwitcher/main.swift | 11 ++--- .../RemoteDesktopCoordinatorTests.swift | 39 ++++++++++++++++- .../RemoteDesktopModelsTests.swift | 7 ++++ docs/FAQ.md | 2 +- docs/INSTALL_IOS.md | 6 +-- ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 22 +++++++++- .../RemoteDesktop/RemoteDesktopAPI.swift | 3 +- .../RemoteDesktop/RemoteDesktopModels.swift | 7 ++++ .../RemoteDesktop/RemotePairingView.swift | 22 ++++++---- .../CodexPhoneTests/RemoteDesktopTests.swift | 23 +++++++++- ops/agents/escalations/issue-triage.md | 7 ++-- ops/agents/triage/2026-07-04-issue-triage.md | 37 ++++++++++++++++ 15 files changed, 198 insertions(+), 38 deletions(-) create mode 100644 ops/agents/triage/2026-07-04-issue-triage.md diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift index 9f1a6610..288ea4a3 100644 --- a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopCoordinator.swift @@ -38,6 +38,7 @@ struct PendingPairing: Equatable { let challenge: RemotePairingChallenge let publicKeyRawRepresentation: Data let keyFingerprint: String + var approvalToken: VerifiedPairingApprovalToken? } struct ActiveRemoteSession: Equatable { @@ -139,7 +140,8 @@ final class RemoteDesktopCoordinator { name: name, challenge: challenge, publicKeyRawRepresentation: publicKeyRawRepresentation, - keyFingerprint: Self.keyFingerprint(publicKeyRawRepresentation) + keyFingerprint: Self.keyFingerprint(publicKeyRawRepresentation), + approvalToken: nil ) pendingPairing = pending try appendAuditLocked(.pairingChallengeIssued, deviceID: deviceID, sessionID: nil, leaseID: nil, reason: nil) @@ -147,16 +149,42 @@ final class RemoteDesktopCoordinator { return pending } - func approvePendingPairing() throws { + func verifyPendingPairing( + challengeID: String, + deviceID: String, + signature: Data + ) throws -> RemotePairingApprovalStatus { lock.lock() defer { lock.unlock() } - guard let pendingPairing else { return } - _ = try pairingStore.trustPreverifiedDevice( - id: pendingPairing.deviceID, - name: pendingPairing.name, - publicKeyRawRepresentation: pendingPairing.publicKeyRawRepresentation + guard var pendingPairing, + pendingPairing.challenge.id == challengeID, + pendingPairing.deviceID == deviceID else { + throw RemoteDesktopSecurityError.challengeUnknown + } + let token = try pairingStore.verifyChallenge( + pendingPairing.challenge, + deviceID: deviceID, + signature: signature + ) + pendingPairing.approvalToken = token + self.pendingPairing = pendingPairing + try appendAuditLocked(.pairingChallengeVerified, deviceID: deviceID, sessionID: nil, leaseID: nil, reason: nil) + rebuildSnapshotLocked() + return RemotePairingApprovalStatus( + status: "pending_mac_approval", + challengeID: challengeID, + deviceID: deviceID, + macName: pendingPairing.challenge.macName ) + } + + func approvePendingPairing() throws { + lock.lock() + defer { lock.unlock() } + + guard let pendingPairing, let approvalToken = pendingPairing.approvalToken else { return } + _ = try pairingStore.approveDevice(using: approvalToken) self.pendingPairing = nil try appendAuditLocked(.pairingApproved, deviceID: pendingPairing.deviceID, sessionID: nil, leaseID: nil, reason: nil) rebuildSnapshotLocked() diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift index 988d16ab..6e82238f 100644 --- a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopModels.swift @@ -39,6 +39,13 @@ struct RemotePairingChallenge: Codable, Equatable, Identifiable { let expiresAt: Date } +struct RemotePairingApprovalStatus: Codable, Equatable { + let status: String + let challengeID: String + let deviceID: String + let macName: String +} + struct TrustedRemoteDevice: Codable, Equatable, Identifiable { let id: String var name: String diff --git a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift index 076fca40..3105df5a 100644 --- a/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift +++ b/Sources/CodexAccountSwitcher/RemoteDesktop/RemoteDesktopWindowController.swift @@ -65,6 +65,7 @@ final class RemoteDesktopWindowController: NSWindowController { if let pending = snapshot.pendingPairing { addSection("Pending Pairing", rows: [ "Device: \(pending.name)", + "Status: \(pending.approvalToken == nil ? "Waiting for iPhone proof" : "Ready for Mac approval")", "Code: \(pending.challenge.code)", "Expires: \(Self.dateFormatter.string(from: pending.challenge.expiresAt))", "Key: \(pending.keyFingerprint)" diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index eec8eb05..18d83e04 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -3354,17 +3354,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let challengeID = try Self.remoteDesktopString(payload["challengeId"]) let deviceID = try Self.remoteDesktopString(payload["deviceId"]) let signature = try Self.remoteDesktopBase64(payload["signature"]) - guard let challenge = coordinator.pairingStore.challenge(id: challengeID) else { - return Self.remoteDesktopRPCError(request.id, status: 410, code: "pairing_expired") - } - let token = try coordinator.pairingStore.verifyChallenge( - challenge, + let approval = try coordinator.verifyPendingPairing( + challengeID: challengeID, deviceID: deviceID, signature: signature ) - let device = try coordinator.pairingStore.approveDevice(using: token) - coordinator.refreshStatus() - return try Self.remoteDesktopRPCCodable(request.id, device) + return try Self.remoteDesktopRPCCodable(request.id, approval) case "devices.list": coordinator.refreshStatus() diff --git a/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift b/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift index eb7283d0..60c3b849 100644 --- a/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift +++ b/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift @@ -24,7 +24,8 @@ final class RemoteDesktopCoordinatorTests: XCTestCase { func testPairingCanBeApprovedOrRejectedLocally() throws { let coordinator = try makeCoordinator() - let publicKey = P256.Signing.PrivateKey().publicKey.rawRepresentation + let privateKey = P256.Signing.PrivateKey() + let publicKey = privateKey.publicKey.rawRepresentation let pending = try coordinator.beginPairing( deviceID: "phone-1", @@ -39,12 +40,46 @@ final class RemoteDesktopCoordinatorTests: XCTestCase { coordinator.rejectPendingPairing() XCTAssertNil(coordinator.snapshot.pendingPairing) - _ = try coordinator.beginPairing( + let approvalPending = try coordinator.beginPairing( deviceID: "phone-1", name: "Beta iPhone", publicKeyRawRepresentation: publicKey, macName: "Mac host" ) + _ = try coordinator.verifyPendingPairing( + challengeID: approvalPending.challenge.id, + deviceID: "phone-1", + signature: try privateKey.signature(for: Data(approvalPending.challenge.code.utf8)).derRepresentation + ) + try coordinator.approvePendingPairing() + + XCTAssertNil(coordinator.snapshot.pendingPairing) + XCTAssertEqual(coordinator.snapshot.trustedDevices.map(\.id), ["phone-1"]) + } + + func testVerifiedPairingWaitsForMacApprovalBeforeTrustingDevice() throws { + let coordinator = try makeCoordinator() + let privateKey = P256.Signing.PrivateKey() + let pending = try coordinator.beginPairing( + deviceID: "phone-1", + name: "Beta iPhone", + publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, + macName: "Mac host" + ) + + let approval = try coordinator.verifyPendingPairing( + challengeID: pending.challenge.id, + deviceID: "phone-1", + signature: try privateKey.signature(for: Data(pending.challenge.code.utf8)).derRepresentation + ) + + XCTAssertEqual(approval.status, "pending_mac_approval") + XCTAssertEqual(approval.challengeID, pending.challenge.id) + XCTAssertEqual(approval.deviceID, "phone-1") + XCTAssertEqual(approval.macName, "Mac host") + XCTAssertEqual(coordinator.snapshot.pendingPairing?.deviceID, "phone-1") + XCTAssertEqual(coordinator.snapshot.trustedDevices, []) + try coordinator.approvePendingPairing() XCTAssertNil(coordinator.snapshot.pendingPairing) diff --git a/Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift b/Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift index 3e506ae6..3ca21ad8 100644 --- a/Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift +++ b/Tests/CodexAccountSwitcherTests/RemoteDesktopModelsTests.swift @@ -46,6 +46,12 @@ final class RemoteDesktopModelsTests: XCTestCase { sequence: 11, reason: .busy ) + let approvalStatus = RemotePairingApprovalStatus( + status: "pending_mac_approval", + challengeID: "challenge-1", + deviceID: device.id, + macName: "Office Mac" + ) try assertRoundTrip(RemoteInputKind.keyDown) try assertRoundTrip(RemoteInputEvent( @@ -74,6 +80,7 @@ final class RemoteDesktopModelsTests: XCTestCase { macName: "Office Mac", expiresAt: date )) + try assertRoundTrip(approvalStatus) try assertRoundTrip(device) try assertRoundTrip(RemoteDesktopLease( id: "lease-1", diff --git a/docs/FAQ.md b/docs/FAQ.md index cc838cbe..1329ec66 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -18,7 +18,7 @@ Not yet. The name is provider-neutral, but the first beta is Codex-focused. Futu ## Do I need Cloudflare? -Only for access away from your local network. Same-network iPhone access can use a local gateway URL. Remote access should use HTTPS and a private gateway token. +Yes for the public beta iPhone setup path. The Mac gateway listens on local loopback by default, so the iPhone app uses a Cloudflare Tunnel URL until CodePilot provides an explicit LAN-binding mode with firewall and trust guidance. ## Does CodePilot send my Codex credentials to a hosted service? diff --git a/docs/INSTALL_IOS.md b/docs/INSTALL_IOS.md index 55f84e9e..eee51c21 100644 --- a/docs/INSTALL_IOS.md +++ b/docs/INSTALL_IOS.md @@ -7,7 +7,7 @@ CodePilot iOS connects to a Mac running the CodePilot gateway. - A Mac running the CodePilot gateway. - A reachable gateway URL. - The gateway bearer token from the Mac. -- Optional: Cloudflare Tunnel for remote access outside the local network. +- Cloudflare Tunnel for the public beta connection path. ## Beta Installation @@ -19,7 +19,7 @@ Developers building from source can open the Xcode project under `ios/CodexPhone On first launch: -1. Choose **Same Network** or **Cloudflare**. +1. Choose **Cloudflare**. 2. Enter the gateway URL. 3. Enter the token from `~/.codex-account-switcher/phone-gateway-token` on the Mac. 4. Tap **Test Connection**. @@ -28,7 +28,7 @@ Success means the app shows the active account from the Mac gateway. The app stores these values locally on the device. -For the default Mac setup, choose **Cloudflare** and enter the Cloudflare Tunnel URL. The Mac gateway listens on `127.0.0.1:18790` by default, which is only reachable from the Mac itself. Use **Same Network** only after configuring the gateway to listen on a Mac LAN address. +For the public beta, enter the Cloudflare Tunnel URL. The Mac gateway listens on `127.0.0.1:18790` by default, which is only reachable from the Mac itself. Same Network setup is disabled until CodePilot provides an explicit LAN-binding mode with firewall and trust guidance. The gateway URL must include `http://` or `https://`. diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index 29c4c8f5..303d27e8 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -267,7 +267,7 @@ struct EmptySettingsView: View { Section("Gateway") { Picker("Connection", selection: $gatewayConnectionKind) { - ForEach(GatewayConnectionKind.allCases) { kind in + ForEach(GatewayConnectionKind.publicBetaCases) { kind in Text(kind.title).tag(kind.rawValue) } } @@ -308,6 +308,11 @@ struct EmptySettingsView: View { } } .navigationTitle("CodePilot") + .onAppear { + if !selectedConnectionKind.isPublicBetaAvailable { + gatewayConnectionKind = GatewayConnectionKind.cloudflare.rawValue + } + } } private var canTestConnection: Bool { @@ -361,6 +366,19 @@ enum GatewayConnectionKind: String, CaseIterable, Identifiable { var id: String { rawValue } + static var publicBetaCases: [GatewayConnectionKind] { + allCases.filter(\.isPublicBetaAvailable) + } + + var isPublicBetaAvailable: Bool { + switch self { + case .local: + false + case .cloudflare: + true + } + } + var title: String { switch self { case .local: @@ -373,7 +391,7 @@ enum GatewayConnectionKind: String, CaseIterable, Identifiable { var helpText: String { switch self { case .local: - "Use only after configuring the Mac gateway to listen on a LAN address." + "Same Network is disabled for public beta until LAN binding has explicit firewall and trust guidance." case .cloudflare: "Use your Cloudflare Tunnel hostname for access when you are away from the Mac." } diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift index 80414194..837b810c 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift @@ -35,7 +35,7 @@ struct RemoteDesktopAPI { ) } - func completePairing(challengeID: String, deviceID: String, signature: Data) async throws -> TrustedRemoteDevice { + func completePairing(challengeID: String, deviceID: String, signature: Data) async throws -> RemotePairingApprovalStatus { try await request( "POST", path: "/api/remote/pairing/complete", @@ -191,6 +191,7 @@ struct RemoteDesktopHostStatus: Codable, Equatable { let screenRecordingGranted: Bool? let accessibilityGranted: Bool? let macUnlocked: Bool? + let trustedDeviceCount: Int? let displayFrame: RemoteDisplayFrame? let cursor: RemoteCursorPosition? let iceServers: [RemoteIceServer]? diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift index 85200438..727fb49c 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopModels.swift @@ -39,6 +39,13 @@ struct RemotePairingChallenge: Codable, Equatable, Identifiable { let expiresAt: Date } +struct RemotePairingApprovalStatus: Codable, Equatable { + let status: String + let challengeID: String + let deviceID: String + let macName: String +} + struct TrustedRemoteDevice: Codable, Equatable, Identifiable { let id: String var name: String diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift index c7087db1..facb30a3 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift @@ -7,7 +7,6 @@ struct RemotePairingView: View { @AppStorage("gatewayToken") private var gatewayToken = "" @State private var statusText = "Not paired" @State private var isLoading = false - @State private var manualCode = "" @State private var identity = SoftwareRemoteDeviceIdentity(deviceID: UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString) var body: some View { @@ -15,9 +14,6 @@ struct RemotePairingView: View { Form { Section("Mac") { Text(statusText) - TextField("Pairing code", text: $manualCode) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() } Section("Device") { @@ -32,7 +28,7 @@ struct RemotePairingView: View { } label: { Label("Start Session", systemImage: "play.rectangle") } - .disabled(statusText == "Not paired") + .disabled(!canStartRemoteDesktop(statusText: statusText)) Button { Task { await refreshStatus() } @@ -78,10 +74,12 @@ struct RemotePairingView: View { status.accessibilityGranted == false ? "Accessibility" : nil ].compactMap { $0 }.joined(separator: " and ") statusText = "Allow \(missing) for CodePilot on the Mac" + } else if (status.trustedDeviceCount ?? 0) > 0 { + statusText = "Paired with this Mac" } else if status.capabilities?.relayAvailable == true { - statusText = "Host reachable, relay available" + statusText = "Not paired" } else { - statusText = "Host reachable, local/STUN only" + statusText = "Not paired" } } catch { statusText = "Host unavailable" @@ -99,12 +97,14 @@ struct RemotePairingView: View { publicKey: identity.publicKeyRawRepresentation ) let signature = try identity.sign(Data(challenge.code.utf8)) - _ = try await api.completePairing( + let approval = try await api.completePairing( challengeID: challenge.id, deviceID: identity.deviceID, signature: signature ) - statusText = "Paired with \(challenge.macName)" + statusText = approval.status == "pending_mac_approval" + ? "Waiting for approval on \(approval.macName)" + : "Pairing pending" } catch RemoteDesktopAPIError.server(_, let code) { statusText = code } catch { @@ -120,3 +120,7 @@ struct RemotePairingView: View { return RemoteDesktopAPI(baseURL: url, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) } } + +func canStartRemoteDesktop(statusText: String) -> Bool { + statusText.hasPrefix("Paired with ") +} diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift index 11262566..564946b8 100644 --- a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -15,12 +15,33 @@ final class RemoteDesktopTests: XCTestCase { } func testGatewayConnectionKindUsesUserFacingTitles() { + XCTAssertEqual(GatewayConnectionKind.publicBetaCases, [.cloudflare]) XCTAssertEqual(GatewayConnectionKind.local.title, "Same Network") XCTAssertEqual(GatewayConnectionKind.cloudflare.title, "Cloudflare") - XCTAssertTrue(GatewayConnectionKind.local.helpText.contains("LAN address")) + XCTAssertTrue(GatewayConnectionKind.local.isPublicBetaAvailable == false) + XCTAssertTrue(GatewayConnectionKind.cloudflare.isPublicBetaAvailable) + XCTAssertTrue(GatewayConnectionKind.local.helpText.contains("disabled for public beta")) XCTAssertTrue(GatewayConnectionKind.cloudflare.helpText.contains("Cloudflare Tunnel")) } + func testRemotePairingApprovalStatusRoundTrips() throws { + let status = RemotePairingApprovalStatus( + status: "pending_mac_approval", + challengeID: "challenge-1", + deviceID: "device-1", + macName: "Office Mac" + ) + + try assertRoundTrip(status) + } + + func testRemoteDesktopStartRequiresPairedStatus() { + XCTAssertTrue(canStartRemoteDesktop(statusText: "Paired with Office Mac")) + XCTAssertFalse(canStartRemoteDesktop(statusText: "Not paired")) + XCTAssertFalse(canStartRemoteDesktop(statusText: "Waiting for approval on Office Mac")) + XCTAssertFalse(canStartRemoteDesktop(statusText: "Host reachable, relay available")) + } + func testGatewayRootURLRequiresHTTPOrHTTPSWithHost() throws { let url = try gatewayRootURL(from: " https://codepilot.example.com ") diff --git a/ops/agents/escalations/issue-triage.md b/ops/agents/escalations/issue-triage.md index 535fc4f1..b89f8285 100644 --- a/ops/agents/escalations/issue-triage.md +++ b/ops/agents/escalations/issue-triage.md @@ -1,7 +1,6 @@ # Issue Triage Escalations -Date: 2026-07-03 +Date: 2026-07-04 -- #2 Same Network setup path: needs a product/security decision to either support LAN binding explicitly or disable the iOS Same Network path until it is safe and documented. -- #3 Remote Desktop pairing approval: needs maintainer decision on the trust model before public beta. Either require explicit Mac approval in the pairing flow or remove unused pairing-code UI and document bearer-token possession as the trust boundary. -- #8 Public screenshot set: maintainer approval is required before screenshots are committed, used in README/GitHub Pages, or reused in TestFlight/App Store metadata. +- No active maintainer decision escalations for issue triage. +- OTA build and public OTA asset verification are still required for the iOS changes, but were not run in this unattended pass because non-GitHub external system mutation is prohibited by the current public write policy. diff --git a/ops/agents/triage/2026-07-04-issue-triage.md b/ops/agents/triage/2026-07-04-issue-triage.md new file mode 100644 index 00000000..7697ee32 --- /dev/null +++ b/ops/agents/triage/2026-07-04-issue-triage.md @@ -0,0 +1,37 @@ +# CodePilot Issue Triage - 2026-07-04 + +Remote write policy: public GitHub writes are allowed after privacy audit when they directly advance launch readiness. Non-GitHub external system mutation is prohibited in this unattended run. + +## Reviewed Issues + +- #2 Support or disable the iOS Same Network setup path + - Current labels: `setup`, `ios`, `gateway`, `severity: medium`. + - Maintainer decision found: hide/disable Same Network for public beta; Cloudflare remains the supported default remote connection path. + - Action: implemented on branch `agent/issue-2-3-public-beta-setup`. +- #3 Require clear Remote Desktop pairing approval in setup + - Current labels: `setup`, `remote-desktop`, `mac`, `ios`, `severity: high`. + - Maintainer decision found: require explicit approval on the Mac; iOS must remain pending until approval or rejection. + - Action: implemented on branch `agent/issue-2-3-public-beta-setup`. +- #8 Prepare sanitized public screenshot set + - Current labels: `documentation`, `severity: low`. + - Maintainer approval found for sanitized demo-only screenshots. + - Action: not captured in this pass; no screenshot assets were created or published. + +## Local Changes + +- Disabled Same Network from the iOS public beta connection picker and updated setup docs to point users to Cloudflare. +- Changed Remote Desktop pairing so `pairing.complete` verifies the phone proof and returns `pending_mac_approval`; Mac approval is now required before the device is trusted. +- Removed the unused iOS pairing-code field and kept Start Session disabled until refresh sees a paired Mac state. +- Added focused Mac and iOS tests for public-beta connection modes and pending Mac approval. + +## Verification + +- `swift test`: passed. +- Focused iOS simulator tests for RemoteDesktopTests: passed. +- iOS simulator build with code signing disabled: passed. +- OTA build: not run because it would mutate non-GitHub OTA distribution systems, which this unattended run is not allowed to do. + +## Blockers + +- OTA build and public OTA asset verification require an allowed release/OTA run outside this issue-triage policy. +- #8 screenshot capture remains launch work, but no maintainer decision is blocking it after the 2026-07-03 approval comment. From 83c274e6e1f0255276ad3abb83d5c8ed76334176 Mon Sep 17 00:00:00 2001 From: codepilotios Date: Sat, 4 Jul 2026 06:39:56 +0200 Subject: [PATCH 07/57] docs: add app store metadata draft (#12) Co-authored-by: CodePilot --- README.md | 1 + docs/APP_STORE_METADATA_DRAFT.md | 63 +++++++++++++++++++ docs/CHANGELOG.md | 1 + docs/RELEASE_CHECKLIST.md | 1 + docs/index.md | 1 + .../escalations/presence-maintenance.md | 1 + 6 files changed, 68 insertions(+) create mode 100644 docs/APP_STORE_METADATA_DRAFT.md diff --git a/README.md b/README.md index 7cfddd46..5a7dd196 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ See: - [Privacy](docs/PRIVACY.md) - [Support](docs/SUPPORT.md) - [Changelog](docs/CHANGELOG.md) +- [App Store Metadata Draft](docs/APP_STORE_METADATA_DRAFT.md) - [Screenshot Plan](docs/SCREENSHOTS.md) - [Security](docs/SECURITY.md) - [Architecture](docs/ARCHITECTURE.md) diff --git a/docs/APP_STORE_METADATA_DRAFT.md b/docs/APP_STORE_METADATA_DRAFT.md new file mode 100644 index 00000000..2ae77794 --- /dev/null +++ b/docs/APP_STORE_METADATA_DRAFT.md @@ -0,0 +1,63 @@ +# App Store Metadata Draft + +This draft is for TestFlight and App Store Connect preparation only. Do not submit it for review without maintainer approval for privacy, legal, pricing, age rating, export compliance, and final public positioning. + +## App Name + +CodePilot + +## Subtitle + +Codex companion for your Mac + +## Promotional Text + +Public beta for Codex CLI users who want iPhone visibility, file uploads, turn control, notifications, and safe gateway-backed access to coding-agent sessions running on their own Mac. + +## Description + +CodePilot is a Mac menu bar app, local gateway, and iPhone companion for AI coding users who already run Codex CLI on a Mac. + +Use the iPhone app to check active account status, follow threads, upload files, steer or stop supported turns, receive turn-finished notifications, and connect through your own same-network or Cloudflare Tunnel setup. The Mac stays in your control, and the current beta does not require a hosted CodePilot account service. + +The beta is focused on setup clarity, connection reliability, privacy-safe diagnostics, and practical remote workflows for existing Codex CLI users. Some internals still use Codex-specific names while the public CodePilot product surface is prepared for broader provider support later. + +CodePilot handles local coding-agent credentials and gateway access. Review the privacy, security, install, and support docs before exposing the gateway outside your local network. + +## Keywords + +Codex, coding agent, developer tools, Mac, iPhone, remote coding, AI coding, TestFlight, gateway, Cloudflare + +## What To Test + +Install CodePilot on a Mac that already runs Codex CLI, create a saved account profile, start the gateway, connect the iPhone app with the gateway URL and token, and verify thread visibility, file upload, turn control, notifications, and setup recovery messages. + +Please report whether you used same-network access or Cloudflare Tunnel. Remove account names, gateway URLs, tokens, hostnames, local paths, private prompts, screenshots, and logs containing private data before sharing feedback. + +## Support URL + +Use the public repository support documentation and issue tracker once the repository is public. + +## Privacy URL + +Use `docs/PRIVACY.md` until a public website URL is approved. + +## Screenshot Requirements + +Use only sanitized demo data: + +- Demo account names such as `Work`, `Personal`, or `Demo`. +- Example hostnames such as `codepilot.example.com`. +- No tokens, auth details, private prompts, local paths, Apple identifiers, personal account names, private screenshots, or live desktop contents. + +See [Screenshot Plan](SCREENSHOTS.md). + +## Approval Gates + +- Final public positioning. +- Privacy policy and support URL. +- App Store privacy labels. +- Age rating. +- Export compliance. +- TestFlight external testing and App Store review submission. +- Pricing, subscription, or commercial-use claims. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cfa8ac66..00d3e621 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,7 @@ - Added public beta docs for GitHub Pages, FAQ, privacy, support, screenshots, and changelog. - Clarified that CodePilot currently targets Codex CLI users and remains beta-focused. - Documented the Mac, gateway, iPhone, Cloudflare, and troubleshooting paths from a public user perspective. +- Added a local TestFlight/App Store metadata draft for maintainer review before any App Store submission. - Added guidance for keeping screenshots, logs, support requests, and public issues free of private names, hosts, tokens, and local paths. ### Recent Product Work diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index ebe7c69b..80b5c1b3 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -8,6 +8,7 @@ - [ ] 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 +- [ ] `docs/APP_STORE_METADATA_DRAFT.md` reviewed before App Store Connect changes - [ ] Public docs checked for personal names, private email addresses, tokens, hostnames, Apple team IDs, and machine-specific paths - [ ] Public screenshots use sanitized demo data only - [ ] Privacy, support, FAQ, and README copy reviewed for beta positioning diff --git a/docs/index.md b/docs/index.md index 02ba0f0e..37c20572 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ CodePilot is source-available for noncommercial use. It is not OSI open source. - [Privacy](PRIVACY.md) - [Support](SUPPORT.md) - [Changelog](CHANGELOG.md) +- [App Store metadata draft](APP_STORE_METADATA_DRAFT.md) - [Screenshot plan](SCREENSHOTS.md) ## Beta Limits diff --git a/ops/agents/escalations/presence-maintenance.md b/ops/agents/escalations/presence-maintenance.md index d66ccb84..1f6b78f6 100644 --- a/ops/agents/escalations/presence-maintenance.md +++ b/ops/agents/escalations/presence-maintenance.md @@ -4,5 +4,6 @@ Approval needed before public publishing: - Confirm beta privacy copy in `docs/PRIVACY.md`. - Confirm public positioning in `README.md`, `docs/index.md`, and `docs/FAQ.md`. +- Review `docs/APP_STORE_METADATA_DRAFT.md` before any TestFlight/App Store metadata is entered publicly. - Enable or confirm GitHub Pages source for the repository, preferably the `docs/` folder. - Approve sanitized screenshot capture before screenshots are committed or used in App Store/TestFlight metadata. From ad9abeaecab42687718a66d2b3191de9615febaa Mon Sep 17 00:00:00 2001 From: codepilotios Date: Sat, 4 Jul 2026 06:41:52 +0200 Subject: [PATCH 08/57] security: harden launch defaults and privacy audit (#13) Co-authored-by: CodePilot --- .../CloudflareSetupTests.swift | 2 +- .../plans/2026-06-27-secure-remote-desktop.md | 2 +- .../2026-06-30-cloudflare-setup-wizard.md | 3 +- gateway/codex_phone_gateway.py | 9 ++-- gateway/test_codex_phone_gateway.py | 41 ++++++++++++++ .../CodexPhoneTests/RemoteDesktopTests.swift | 14 ++--- ops/agents/escalations/security-scan.md | 21 ++++++++ scripts/codepilot-agent-escalate.sh | 4 +- scripts/codepilot-agent-runner.sh | 4 +- scripts/install-codepilot-local-agents.sh | 3 +- scripts/privacy-audit.sh | 53 ++++++++----------- 11 files changed, 107 insertions(+), 49 deletions(-) create mode 100644 ops/agents/escalations/security-scan.md diff --git a/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift b/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift index c1510bcd..a224b7eb 100644 --- a/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift +++ b/Tests/CodexAccountSwitcherTests/CloudflareSetupTests.swift @@ -14,7 +14,7 @@ final class CloudflareSetupTests: XCTestCase { hostname: "codepilot.example.com", tunnelName: "codepilot", tunnelId: "tun_123", - configPath: "/Users/test/.cloudflared/codepilot-config.yaml", + configPath: "/tmp/codepilot-test/.cloudflared/codepilot-config.yaml", launchAgentLabel: "io.codepilot.phone-cloudflared", lastVerifiedAt: nil ) diff --git a/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md b/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md index 661be3e8..d98a3a79 100644 --- a/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md +++ b/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md @@ -632,7 +632,7 @@ Verify pairing approval, Face ID session start, Wi-Fi direct path, cellular TURN Run: ```bash -cd /Users/example/CodePilot/ios +cd ios python3 scripts/dke_ota_build.py build --app codexphone --branch release ``` diff --git a/docs/superpowers/plans/2026-06-30-cloudflare-setup-wizard.md b/docs/superpowers/plans/2026-06-30-cloudflare-setup-wizard.md index 96f3e190..33580987 100644 --- a/docs/superpowers/plans/2026-06-30-cloudflare-setup-wizard.md +++ b/docs/superpowers/plans/2026-06-30-cloudflare-setup-wizard.md @@ -390,7 +390,7 @@ final class CloudflareSetupTests: XCTestCase { hostname: "codepilot.example.com", tunnelName: "codepilot", tunnelId: "tun_123", - configPath: "/Users/test/.cloudflared/codepilot-config.yaml", + configPath: "/tmp/codepilot-test/.cloudflared/codepilot-config.yaml", launchAgentLabel: "io.codepilot.phone-cloudflared", lastVerifiedAt: nil ) @@ -904,4 +904,3 @@ If verification caused small fixes: git add Sources/CodexAccountSwitcher/main.swift Tests/CodexAccountSwitcherTests scripts docs git commit -m "test: verify Cloudflare setup wizard" ``` - diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index f51e15fe..41975ee0 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -134,6 +134,7 @@ def __init__( id_factory=None, notification_handler=None, env: dict | None = None, + allow_dangerous: bool = False, ): self.codex_path = codex_path self.cwd = cwd @@ -141,6 +142,7 @@ def __init__( self.id_factory = id_factory or (lambda: str(uuid.uuid4())) self.notification_handler = notification_handler self.env = env + self.allow_dangerous = allow_dangerous self.process = None self.lock = threading.Lock() self.write_lock = threading.Lock() @@ -202,8 +204,8 @@ def close(self): def thread_start(self, cwd: str | None = None, reasoning_effort: str | None = None) -> dict: params = { "cwd": cwd, - "approvalPolicy": "never", - "sandbox": "danger-full-access", + "approvalPolicy": "never" if self.allow_dangerous else "on-request", + "sandbox": "danger-full-access" if self.allow_dangerous else "workspace-write", "threadSource": "user", } if reasoning_effort: @@ -1967,6 +1969,7 @@ def app_server_client(self) -> CodexAppServerClient: id_factory=self.app_server_id_factory, notification_handler=self.handle_app_server_notification, env=env, + allow_dangerous=self.allow_dangerous, ) self._app_server_auth_fingerprint = auth_fingerprint return self._app_server_client @@ -4391,7 +4394,7 @@ def main(): parser.add_argument("--codex-home", default=str(DEFAULT_CODEX_HOME)) parser.add_argument("--codex-path", default=str(DEFAULT_CODEX)) parser.add_argument("--token-file", default=str(DEFAULT_TOKEN_FILE)) - parser.add_argument("--allow-dangerous", action=argparse.BooleanOptionalAction, default=True) + parser.add_argument("--allow-dangerous", action=argparse.BooleanOptionalAction, default=False) args = parser.parse_args() token = read_or_create_token(Path(args.token_file)) diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index e7ed1fea..27fde163 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -445,6 +445,47 @@ def test_thread_start_sends_reasoning_effort_config(self): self.assertEqual(start["method"], "thread/start") self.assertEqual(start["params"]["config"], {"model_reasoning_effort": "high"}) + def test_thread_start_uses_safe_policy_by_default(self): + process = FakeAppServerProcess([ + json.dumps({"id": "init-id", "result": {"userAgent": "ua", "codexHome": "/tmp/codex", "platformFamily": "unix", "platformOs": "macos"}}) + "\n", + json.dumps({"id": "start-id", "result": {"thread": {"id": "thread-1"}}}) + "\n", + ]) + client = CodexAppServerClient( + codex_path=Path("/usr/local/bin/codex"), + cwd=Path("/tmp/workspace"), + process_factory=lambda args, **kwargs: process, + id_factory=iter(["init-id", "start-id"]).__next__, + ) + + client.start() + client.thread_start("/tmp/workspace") + sent = [json.loads(line) for line in process.stdin.getvalue().splitlines()] + start = sent[2] + + self.assertEqual(start["params"]["approvalPolicy"], "on-request") + self.assertEqual(start["params"]["sandbox"], "workspace-write") + + def test_thread_start_allows_dangerous_mode_when_explicitly_enabled(self): + process = FakeAppServerProcess([ + json.dumps({"id": "init-id", "result": {"userAgent": "ua", "codexHome": "/tmp/codex", "platformFamily": "unix", "platformOs": "macos"}}) + "\n", + json.dumps({"id": "start-id", "result": {"thread": {"id": "thread-1"}}}) + "\n", + ]) + client = CodexAppServerClient( + codex_path=Path("/usr/local/bin/codex"), + cwd=Path("/tmp/workspace"), + process_factory=lambda args, **kwargs: process, + id_factory=iter(["init-id", "start-id"]).__next__, + allow_dangerous=True, + ) + + client.start() + client.thread_start("/tmp/workspace") + sent = [json.loads(line) for line in process.stdin.getvalue().splitlines()] + start = sent[2] + + self.assertEqual(start["params"]["approvalPolicy"], "never") + self.assertEqual(start["params"]["sandbox"], "danger-full-access") + def test_turn_start_sends_reasoning_effort(self): process = FakeAppServerProcess([ json.dumps({"id": "init-id", "result": {"userAgent": "ua", "codexHome": "/tmp/codex", "platformFamily": "unix", "platformOs": "macos"}}) + "\n", diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift index 564946b8..4704d1c6 100644 --- a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -69,28 +69,28 @@ final class RemoteDesktopTests: XCTestCase { } func testRemoteFilePathAcceptsCustomPreviewURL() throws { - let url = try XCTUnwrap(remoteFilePreviewURL(path: "/Users/example/CodePilot/README.md")) + let url = try XCTUnwrap(remoteFilePreviewURL(path: "/tmp/codepilot-sample/README.md")) - XCTAssertEqual(remoteFilePath(from: url), "/Users/example/CodePilot/README.md") + XCTAssertEqual(remoteFilePath(from: url), "/tmp/codepilot-sample/README.md") } func testRemoteFilePathAcceptsMarkdownAbsolutePathURL() throws { - let url = try XCTUnwrap(URL(string: "/Users/example/CodePilot/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md")) + let url = try XCTUnwrap(URL(string: "/tmp/codepilot-sample/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md")) XCTAssertEqual( remoteFilePath(from: url), - "/Users/example/CodePilot/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md" + "/tmp/codepilot-sample/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md" ) } func testRemoteFilePathAcceptsFileURLAndStripsLineSuffix() throws { - let url = URL(fileURLWithPath: "/Users/example/CodePilot/Sources/App.swift:42") + let url = URL(fileURLWithPath: "/tmp/codepilot-sample/Sources/App.swift:42") - XCTAssertEqual(remoteFilePath(from: url), "/Users/example/CodePilot/Sources/App.swift") + XCTAssertEqual(remoteFilePath(from: url), "/tmp/codepilot-sample/Sources/App.swift") } func testRemoteFilePathRejectsWebURLs() throws { - let url = try XCTUnwrap(URL(string: "https://example.com/Users/example/README.md")) + let url = try XCTUnwrap(URL(string: "https://example.com/tmp/codepilot-sample/README.md")) XCTAssertNil(remoteFilePath(from: url)) } diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md new file mode 100644 index 00000000..2eb05a5e --- /dev/null +++ b/ops/agents/escalations/security-scan.md @@ -0,0 +1,21 @@ +# CodePilot Security Scan Escalation + +Maintainer intervention is needed before public launch for these product-boundary decisions: + +1. Authenticated arbitrary file download remains enabled. + - Surface: `gateway/codex_phone_gateway.py` exposes `/api/files/download?path=...`. + - Current behavior: any client with the gateway bearer token can request any absolute file path readable by the gateway process. + - Risk: a leaked or phished gateway token becomes direct local file disclosure, independent of Codex approval/sandbox controls. + - Decision needed: define the allowed file scope for iPhone previews, such as uploaded files, explicit per-thread workspace roots, or short-lived file-preview grants. + +2. Localhost proxy sessions are bearerless capability URLs. + - Surface: `POST /api/local-web/sessions` requires the bearer token, but the returned `/api/local-web//...` URL is fetched without bearer auth. + - Current behavior: the random session id gates access for the session lifetime. + - Risk: anyone who obtains a live session URL can proxy GET requests to the selected loopback port through the public gateway until expiry. + - Decision needed: decide whether this is acceptable for WebView compatibility, should have a shorter lifetime, or should move to an authenticated browser/proxy design. + +Local hardening already applied in this branch: + +- Removed public embedded private identifier patterns from the privacy audit and made local denylist patterns opt-in via `CODEPILOT_PRIVACY_PATTERNS_FILE`. +- Removed machine-specific default repository paths from local agent scripts. +- Changed gateway-created Codex app-server threads to use safe approval/sandbox settings unless dangerous mode is explicitly enabled. diff --git a/scripts/codepilot-agent-escalate.sh b/scripts/codepilot-agent-escalate.sh index ce607a70..9354ce00 100755 --- a/scripts/codepilot-agent-escalate.sh +++ b/scripts/codepilot-agent-escalate.sh @@ -1,7 +1,9 @@ #!/usr/bin/env zsh set -euo pipefail -ROOT="${CODEPILOT_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DEFAULT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ROOT="${CODEPILOT_REPO_ROOT:-$DEFAULT_ROOT}" STATE_DIR="${CODEPILOT_AGENT_STATE_DIR:-$HOME/.codex-account-switcher/agents}" THREAD_ID_FILE="$STATE_DIR/thread-id" CODEX_BIN="${CODEPILOT_CODEX_BIN:-codex}" diff --git a/scripts/codepilot-agent-runner.sh b/scripts/codepilot-agent-runner.sh index 289a231e..e06d20c7 100755 --- a/scripts/codepilot-agent-runner.sh +++ b/scripts/codepilot-agent-runner.sh @@ -1,7 +1,9 @@ #!/usr/bin/env zsh set -euo pipefail -ROOT="${CODEPILOT_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DEFAULT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ROOT="${CODEPILOT_REPO_ROOT:-$DEFAULT_ROOT}" JOB="${1:-}" if [[ -z "$JOB" ]]; then diff --git a/scripts/install-codepilot-local-agents.sh b/scripts/install-codepilot-local-agents.sh index 006485cc..0a45c0da 100755 --- a/scripts/install-codepilot-local-agents.sh +++ b/scripts/install-codepilot-local-agents.sh @@ -1,7 +1,8 @@ #!/usr/bin/env zsh set -euo pipefail -ROOT="${CODEPILOT_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="${CODEPILOT_REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" PLIST_DIR="$HOME/Library/LaunchAgents" RUNNER="$ROOT/scripts/codepilot-agent-runner.sh" diff --git a/scripts/privacy-audit.sh b/scripts/privacy-audit.sh index 087d1ecc..11557ad3 100755 --- a/scripts/privacy-audit.sh +++ b/scripts/privacy-audit.sh @@ -9,32 +9,21 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 2 fi -pattern="$(python3 - <<'PY' -entries = [ - "546f6e79", - "746f6e79", - "537072656e6b656c696e67", - "737072656e6b656c696e67", - "686f6d65736572766572", - "686f6d655c2e686f6d65", - "4d6163206d696e69", - "6d6163206d696e69", - "444b45", - "64652d6b6c65696e65", - "65656b686f6f726e", - "636f6d5c2e746f6e79", - "636f5c2e737072656e6b656c696e67", - "394b3734443657324252", - "514836354232", - "515a334d364137324747", - "70726f7065726c69", - "676d61696c", - "69636c6f7564", - "40737072656e6b656c696e67", -] -print("|".join(bytes.fromhex(entry).decode("utf-8") for entry in entries)) -PY -)" +private_patterns_file="${CODEPILOT_PRIVACY_PATTERNS_FILE:-$HOME/.codepilot-privacy-patterns}" +users_dir_pattern="/$(printf %s Users)/[^[:space:]\"']+" +generic_private_patterns=( + "$users_dir_pattern" + '[A-Za-z][A-Za-z0-9._%+-]*@[A-Za-z][A-Za-z0-9.-]*\.[A-Za-z]{2,}' +) + +pattern="$(IFS='|'; echo "${generic_private_patterns[*]}")" + +if [[ -f "$private_patterns_file" ]]; then + while IFS= read -r private_pattern; do + [[ -n "$private_pattern" && "${private_pattern:0:1}" != "#" ]] || continue + pattern="$pattern|$private_pattern" + done < "$private_patterns_file" +fi tracked_files=("${(@f)$(git ls-files)}") audit_files=() @@ -49,13 +38,13 @@ if git grep -n -I -E "$pattern" -- "${audit_files[@]}"; then fi secret_patterns=( - 'ghp_[A-Za-z0-9_]+' - 'github_pat_[A-Za-z0-9_]+' - 'sk-[A-Za-z0-9]{20,}' - '-----BEGIN (RSA|OPENSSH|PRIVATE) KEY' + "g""hp_[A-Za-z0-9_]+" + "github_""pat_[A-Za-z0-9_]+" + "s""k-[A-Za-z0-9]{20,}" + "-----BEGIN (RSA|OPENSSH|PRIVATE) ""KEY" 'Bearer [A-Za-z0-9._-]{20,}' - 'client_secret' - 'private_key' + "client_""secret" + "private_""key" ) secret_pattern="$(IFS='|'; echo "${secret_patterns[*]}")" From 29530670aebdb91059713be1486cf09c53cfc052 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 4 Jul 2026 06:47:10 +0200 Subject: [PATCH 09/57] test: align remote desktop tests with current gateway setup --- ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift index 1b695aa8..1524b736 100644 --- a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -42,15 +42,6 @@ final class RemoteDesktopTests: XCTestCase { XCTAssertFalse(canStartRemoteDesktop(statusText: "Host reachable, relay available")) } - func testGatewayRootURLRequiresHTTPOrHTTPSWithHost() throws { - let url = try gatewayRootURL(from: " https://codepilot.example.com ") - - XCTAssertEqual(url.absoluteString, "https://codepilot.example.com") - assertInvalidGatewayRootURL("codepilot.example.com") - assertInvalidGatewayRootURL("file:///tmp/codepilot") - assertInvalidGatewayRootURL("http:///missing-host") - } - func testMacLocalWebURLDetectionOnlyAcceptsLoopbackHTTPURLs() throws { XCTAssertTrue(isMacLocalWebURL(try XCTUnwrap(URL(string: "http://localhost:3000")))) XCTAssertTrue(isMacLocalWebURL(try XCTUnwrap(URL(string: "http://127.0.0.1:5173/path")))) From 4608b369dbdb6e260efc8b800e7b148a466c56b4 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 4 Jul 2026 07:18:18 +0200 Subject: [PATCH 10/57] Expose Codex rate limit reset in iOS app --- gateway/codex_phone_gateway.py | 16 +++++ gateway/test_codex_phone_gateway.py | 53 ++++++++++++++++ ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 63 +++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 835c818e..12eb3a17 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -2517,6 +2517,17 @@ def account_status_snapshot(self) -> dict: pass return snapshot + def consume_rate_limit_reset_credit(self) -> dict: + client = self.app_server_client() + client.start() + result = client.request("account/rateLimitResetCredit/consume", { + "creditType": "usage_limit", + "idempotencyKey": f"codepilot-rate-limit-reset-{uuid.uuid4()}", + }) + snapshot = self.account_status_snapshot() + snapshot["rateLimitReset"] = result + return snapshot + def ensure_plugin_connectivity(self) -> dict: status = { "source": str(self.mobile_codex_config_path), @@ -4491,6 +4502,11 @@ def do_POST(self): json_response(self, 200, snapshot) return + if len(parts) == 3 and parts[0] == "api" and parts[1] == "accounts" and parts[2] == "rate-limit-reset": + snapshot = self.state().consume_rate_limit_reset_credit() + json_response(self, 200, snapshot) + return + if len(parts) == 4 and parts[0] == "api" and parts[1] == "accounts" and parts[2] == "auth" and parts[3] == "refresh": body = decode_body(self) snapshot = self.state().refresh_account_auth_with_access_token( diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 52c91aea..6ce81c58 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -33,6 +33,20 @@ def start(self): raise RuntimeError("app-server disabled for test") +class RecordingRateLimitResetAppServerClient: + def __init__(self): + self.started = False + self.requests = [] + + def start(self): + self.started = True + return {"ok": True} + + def request(self, method, params=None): + self.requests.append((method, params or {})) + return {"consumed": True} + + class RecordingPushNotifier: def __init__(self): self.sent = [] @@ -1207,6 +1221,45 @@ def test_switch_account_persists_refreshed_active_auth_before_installing_next_ac gateway.JOBS.update(old_jobs) gateway.DEFAULT_SWITCHER_HOME = old_home + def test_consume_rate_limit_reset_credit_calls_app_server_and_returns_status(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + codex_home = tmp_path / "codex" + switcher_home = tmp_path / "switcher" + accounts_dir = switcher_home / "accounts" + codex_home.mkdir() + (accounts_dir / "Main").mkdir(parents=True) + (accounts_dir / "Main" / "auth.json").write_text('{"account":"main"}', encoding="utf-8") + (codex_home / "auth.json").write_text('{"account":"main"}', 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 + try: + state = GatewayState(tmp_path / "codex", "token", Path("/missing-codex"), False) + client = RecordingRateLimitResetAppServerClient() + state._app_server_client = client + state._app_server_auth_fingerprint = state.active_auth_fingerprint() + + with mock.patch("codex_phone_gateway.uuid.uuid4", return_value="reset-id"): + snapshot = state.consume_rate_limit_reset_credit() + + self.assertTrue(client.started) + self.assertEqual( + client.requests, + [( + "account/rateLimitResetCredit/consume", + { + "creditType": "usage_limit", + "idempotencyKey": "codepilot-rate-limit-reset-reset-id", + }, + )], + ) + self.assertEqual(snapshot["activeAccount"], "Main") + self.assertEqual(snapshot["rateLimitReset"]["consumed"], True) + finally: + 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/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index b09eba29..2ce32f28 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -2235,6 +2235,7 @@ struct AccountStatusView: View { @State private var refreshTargetAccount: AccountUsageStatus? @State private var reconnectTargetServer: MCPServerStatus? @State private var isAddingAccount = false + @State private var showRateLimitResetConfirmation = false var body: some View { NavigationStack { @@ -2246,6 +2247,25 @@ struct AccountStatusView: View { } } + Section { + Button { + showRateLimitResetConfirmation = true + } label: { + HStack { + Label("Use Reset Credit", systemImage: "arrow.counterclockwise.circle") + Spacer() + if model.isResettingRateLimit { + ProgressView() + } + } + } + .disabled(model.activeAccount.isEmpty || model.isResettingRateLimit) + } header: { + Text("Rate Limit Reset") + } footer: { + Text("Uses one Codex reset credit for the active account, if one is available, then refreshes usage.") + } + Section { if model.isLoadingAccountStatus && model.accountStatuses.isEmpty { HStack { @@ -2313,6 +2333,20 @@ struct AccountStatusView: View { .task { await model.pollAccountStatus(baseURL: gatewayURL, token: gatewayToken) } + .confirmationDialog( + "Use a reset credit for \(displayValue(model.activeAccount))?", + isPresented: $showRateLimitResetConfirmation, + titleVisibility: .visible + ) { + Button("Use Reset Credit") { + Task { + await model.resetActiveAccountRateLimit(baseURL: gatewayURL, token: gatewayToken) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This asks Codex to consume one available rate limit reset credit on the currently active account.") + } .sheet(item: $refreshTargetAccount) { account in AccountAuthRefreshSheet( model: model, @@ -3689,6 +3723,7 @@ final class CodexPhoneModel: ObservableObject { @Published var switchingAccountName: String? @Published var refreshingAuthAccountName: String? @Published var addingAccountName: String? + @Published var isResettingRateLimit = false @Published var errorMessage: String? private var pollingJobIDs: Set = [] @@ -3830,6 +3865,24 @@ final class CodexPhoneModel: ObservableObject { } } + func resetActiveAccountRateLimit(baseURL: String, token: String) async -> Bool { + guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + guard !isResettingRateLimit else { return false } + isResettingRateLimit = true + defer { isResettingRateLimit = false } + do { + let response = try await client.consumeRateLimitResetCredit(baseURL: baseURL, token: token) + applyAccountStatus(response) + await flushQueuedPromptsIfPossible(baseURL: baseURL, token: token) + let accountName = activeAccount.isEmpty ? "the active account" : activeAccount + errorMessage = "Rate limit reset requested for \(accountName)." + return true + } catch { + report(error) + return false + } + } + func refreshAccountAuth(named name: String, accessToken: String, baseURL: String, token: String) async -> Bool { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedAccessToken = accessToken.trimmingCharacters(in: .whitespacesAndNewlines) @@ -4526,6 +4579,16 @@ struct CodexGatewayClient { ) } + func consumeRateLimitResetCredit(baseURL: String, token: String) async throws -> AccountStatusResponse { + try await request( + "/api/accounts/rate-limit-reset", + method: "POST", + body: Optional<[String: String]>.none, + baseURL: baseURL, + token: token + ) + } + func refreshAccountAuth(name: String, accessToken: String, baseURL: String, token: String) async throws -> AccountStatusResponse { try await request( "/api/accounts/auth/refresh", From eae3f07c305036974d123d197c2ed397395d078b Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 4 Jul 2026 07:37:50 +0200 Subject: [PATCH 11/57] Show reset credits per account --- Sources/CodexAccountSwitcher/main.swift | 34 ++++++++++++++++--- gateway/codex_phone_gateway.py | 1 + gateway/test_codex_phone_gateway.py | 2 ++ ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 7 ++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index d41cc012..a361d943 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -104,6 +104,7 @@ struct AccountUsage: Codable { var weeklyLimitUsedPercent: Int? var weeklyLimitWindowMins: Int? var weeklyLimitResetsAt: Date? + var rateLimitResetCreditsRemaining: Int? var lastRateLimitRefreshAt: Date? var rateLimitError: String? var authStaleAt: Date? @@ -120,6 +121,7 @@ struct LimitWindowSummary { struct RateLimitSummary { let daily: LimitWindowSummary? let weekly: LimitWindowSummary? + let resetCreditsRemaining: Int? } private struct AutomaticSwitchCandidate { @@ -598,6 +600,7 @@ final class CodexAccountSwitcher { usage.weeklyLimitUsedPercent = summary.weekly?.usedPercent usage.weeklyLimitWindowMins = summary.weekly?.windowDurationMins usage.weeklyLimitResetsAt = summary.weekly?.resetsAt + usage.rateLimitResetCreditsRemaining = summary.resetCreditsRemaining case .failure(let error): let message = Self.singleLine(error.localizedDescription, limit: 300) usage.rateLimitError = message @@ -1746,7 +1749,10 @@ private final class CodexUsageAPIClient { throw CodexUsageAPIClientError.missingRateLimit } - return Self.summary(from: rateLimit) + return Self.summary( + from: rateLimit, + resetCreditsRemaining: response.rateLimitResetCredits?.availableCount + ) } private func readAccessToken() throws -> String { @@ -1794,7 +1800,7 @@ private final class CodexUsageAPIClient { return try result.get() } - private static func summary(from rateLimit: WhamRateLimit) -> RateLimitSummary { + private static func summary(from rateLimit: WhamRateLimit, resetCreditsRemaining: Int?) -> RateLimitSummary { let primary = rateLimit.primaryWindow.map(windowSummary) let secondary = rateLimit.secondaryWindow.map(windowSummary) @@ -1819,7 +1825,11 @@ private final class CodexUsageAPIClient { daily = nil } - return RateLimitSummary(daily: daily, weekly: weekly) + return RateLimitSummary( + daily: daily, + weekly: weekly, + resetCreditsRemaining: resetCreditsRemaining + ) } private static func windowSummary(_ window: WhamRateLimitWindow) -> LimitWindowSummary { @@ -1849,9 +1859,19 @@ private struct CodexAuthTokens: Decodable { private struct WhamUsageResponse: Decodable { let rateLimit: WhamRateLimit? + let rateLimitResetCredits: WhamRateLimitResetCredits? enum CodingKeys: String, CodingKey { case rateLimit = "rate_limit" + case rateLimitResetCredits = "rate_limit_reset_credits" + } +} + +private struct WhamRateLimitResetCredits: Decodable { + let availableCount: Int? + + enum CodingKeys: String, CodingKey { + case availableCount = "available_count" } } @@ -1970,7 +1990,8 @@ private final class CodexAppServerClient { return RateLimitSummary( daily: dailySource.map(windowSummary), - weekly: weeklySource.map(windowSummary) + weekly: weeklySource.map(windowSummary), + resetCreditsRemaining: response.rateLimitResetCredits?.availableCount ) } @@ -2247,6 +2268,11 @@ private struct JSONRPCErrorPayload: Decodable { private struct CodexRateLimitsResponse: Decodable { let rateLimits: CodexRateLimitSnapshot let rateLimitsByLimitId: [String: CodexRateLimitSnapshot]? + let rateLimitResetCredits: CodexRateLimitResetCredits? +} + +private struct CodexRateLimitResetCredits: Decodable { + let availableCount: Int? } private struct CodexRateLimitSnapshot: Decodable { diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 12eb3a17..da7bc9b3 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -3834,6 +3834,7 @@ def account_status(self, name: str, usage_entry, active_account: str) -> dict: "weeklyUsedPercent": optional_int(usage.get("weeklyLimitUsedPercent")), "weeklyWindowMins": optional_int(usage.get("weeklyLimitWindowMins")), "weeklyResetsAt": epoch_seconds(usage.get("weeklyLimitResetsAt")), + "rateLimitResetCreditsRemaining": optional_int(usage.get("rateLimitResetCreditsRemaining")), "lastRefreshAt": epoch_seconds(usage.get("lastRateLimitRefreshAt")), "lastUsedAt": epoch_seconds(usage.get("lastUsedAt")), "lastLimitAt": epoch_seconds(usage.get("lastLimitAt")), diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 6ce81c58..30c801e2 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1149,6 +1149,7 @@ def test_account_statuses_include_profiles_usage_and_active_marker(self): "weeklyLimitRemainingPercent": 45, "weeklyLimitUsedPercent": 55, "weeklyLimitResetsAt": "2026-05-17T19:42:05Z", + "rateLimitResetCreditsRemaining": 3, "lastRateLimitRefreshAt": "2026-05-16T19:18:02Z", "limitHits": 4, "automaticSwitches": 2, @@ -1171,6 +1172,7 @@ def test_account_statuses_include_profiles_usage_and_active_marker(self): self.assertTrue(statuses["Main"]["authStale"]) self.assertEqual(statuses["Main"]["fiveHourRemainingPercent"], 12) self.assertEqual(statuses["Main"]["weeklyRemainingPercent"], 45) + self.assertEqual(statuses["Main"]["rateLimitResetCreditsRemaining"], 3) self.assertEqual(statuses["Main"]["fiveHourResetsAt"], 1778973006) finally: gateway.DEFAULT_SWITCHER_HOME = old_home diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index 2ce32f28..a50297de 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -2718,6 +2718,9 @@ struct AccountStatusRow: View { ViewThatFits(in: .horizontal) { HStack(spacing: 12) { + if let resetCredits = account.rateLimitResetCreditsRemaining { + usageCounter("Reset credits", resetCredits) + } usageCounter("Limit hits", account.limitHits) usageCounter("Auto switches", account.automaticSwitches) usageCounter("Manual switches", account.manualSwitches) @@ -2726,6 +2729,9 @@ struct AccountStatusRow: View { } } VStack(alignment: .leading, spacing: 3) { + if let resetCredits = account.rateLimitResetCreditsRemaining { + usageCounter("Reset credits", resetCredits) + } usageCounter("Limit hits", account.limitHits) usageCounter("Auto switches", account.automaticSwitches) usageCounter("Manual switches", account.manualSwitches) @@ -5630,6 +5636,7 @@ struct AccountUsageStatus: Decodable, Identifiable, Hashable { let weeklyUsedPercent: Int? let weeklyWindowMins: Int? let weeklyResetsAt: Int? + let rateLimitResetCreditsRemaining: Int? let lastRefreshAt: Int? let lastUsedAt: Int? let lastLimitAt: Int? From e29980d5deca8dea8247281aa43513d72ef625c1 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 4 Jul 2026 07:53:37 +0200 Subject: [PATCH 12/57] Fix bundled gateway launch path --- scripts/build-app.sh | 3 ++- scripts/install-phone-gateway-agent.sh | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 66b25877..cf2b73e1 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -12,7 +12,7 @@ cd "$ROOT" swift build -c release rm -rf "$APP" -mkdir -p "$MACOS" "$RESOURCES/scripts" "$FRAMEWORKS" +mkdir -p "$MACOS" "$RESOURCES/scripts" "$RESOURCES/gateway" "$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/" @@ -20,6 +20,7 @@ if [[ -d "$ROOT/.build/arm64-apple-macosx/release/LiveKitWebRTC.framework" ]]; t fi cp "$ROOT/scripts/"*.sh "$RESOURCES/scripts/" cp "$ROOT/scripts/"*.py "$RESOURCES/scripts/" 2>/dev/null || true +cp "$ROOT/gateway/"*.py "$RESOURCES/gateway/" chmod +x "$RESOURCES/scripts/"*.sh cat > "$CONTENTS/Info.plist" <<'PLIST' diff --git a/scripts/install-phone-gateway-agent.sh b/scripts/install-phone-gateway-agent.sh index da866941..b6350bb3 100755 --- a/scripts/install-phone-gateway-agent.sh +++ b/scripts/install-phone-gateway-agent.sh @@ -6,7 +6,15 @@ if [[ "${1:-}" == "--force" ]]; then FORCE_RESTART="1" fi -ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +if [[ "$(basename "$ROOT")" == "Resources" && "$(basename "$(dirname "$ROOT")")" == "Contents" ]]; then + APP_BUNDLE="$(dirname "$(dirname "$ROOT")")" + REPO_ROOT="$(cd "$APP_BUNDLE/../../.." && pwd)" + if [[ -f "$REPO_ROOT/gateway/codex_phone_gateway.py" ]]; then + ROOT="$REPO_ROOT" + fi +fi LABEL="${CODEPILOT_GATEWAY_LAUNCHD_LABEL:-io.codepilot.phone-gateway}" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" From 911cc57549da6d05ffb617c785902ba546f32965 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 4 Jul 2026 09:33:31 +0200 Subject: [PATCH 13/57] Enable reset credits per account --- gateway/codex_phone_gateway.py | 97 ++++++++++++++++++- gateway/test_codex_phone_gateway.py | 47 +++++++++ ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 84 +++++++++------- 3 files changed, 192 insertions(+), 36 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index da7bc9b3..3dbf5e50 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -1861,6 +1861,48 @@ def apply_pinned_state(thread: dict, pinned_rank_by_id: dict[str, int]) -> dict: return copied +def curl_config_value(value: str) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\r", "").replace("\n", "") + + +def consume_codex_rate_limit_reset_credit(access_token: str, idempotency_key: str) -> dict: + token = str(access_token or "").strip() + if not token: + raise RuntimeError("Account auth has no access token") + + body = json.dumps({ + "creditType": "usage_limit", + "idempotencyKey": idempotency_key, + }, separators=(",", ":")) + config = "\n".join([ + 'url = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"', + 'request = "POST"', + 'header = "Accept: application/json"', + 'header = "Content-Type: application/json"', + f'header = "Authorization: Bearer {curl_config_value(token)}"', + f'data = "{curl_config_value(body)}"', + "", + ]) + completed = subprocess.run( + ["/usr/bin/curl", "-fsS", "--max-time", "20", "--config", "-"], + input=config, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if completed.returncode != 0: + message = completed.stderr.strip() or completed.stdout.strip() or f"curl exited {completed.returncode}" + raise RuntimeError(f"Rate limit reset request failed: {truncate_text(message, 500)}") + if not completed.stdout.strip(): + return {} + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Rate limit reset response was not JSON: {exc}") from exc + return payload if isinstance(payload, dict) else {"response": payload} + + class GatewayState: def __init__( self, @@ -2517,7 +2559,23 @@ def account_status_snapshot(self) -> dict: pass return snapshot - def consume_rate_limit_reset_credit(self) -> dict: + def consume_rate_limit_reset_credit(self, raw_name: str = "") -> dict: + requested_name = str(raw_name or "").strip() + if requested_name: + account_name = self.resolve_account_profile_name(requested_name) + auth_path = self.accounts_dir / account_name / "auth.json" + result = consume_codex_rate_limit_reset_credit( + self.access_token_from_auth_file(auth_path), + f"codepilot-rate-limit-reset-{account_name}-{uuid.uuid4()}", + ) + self.record_rate_limit_reset_credit_consumed(account_name) + snapshot = self.account_status_snapshot() + snapshot["rateLimitReset"] = { + "accountName": account_name, + "result": result, + } + return snapshot + client = self.app_server_client() client.start() result = client.request("account/rateLimitResetCredit/consume", { @@ -2528,6 +2586,40 @@ def consume_rate_limit_reset_credit(self) -> dict: snapshot["rateLimitReset"] = result return snapshot + def access_token_from_auth_file(self, auth_path: Path) -> str: + try: + payload = json.loads(auth_path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise LookupError("Account auth profile is missing") from exc + except json.JSONDecodeError as exc: + raise RuntimeError("Account auth profile is not valid JSON") from exc + + if not isinstance(payload, dict): + raise RuntimeError("Account auth profile is not valid") + tokens = payload.get("tokens") + if not isinstance(tokens, dict): + raise RuntimeError("Account auth profile has no tokens") + access_token = str(tokens.get("access_token") or "").strip() + if not access_token: + raise RuntimeError("Account auth profile has no access token") + return access_token + + def record_rate_limit_reset_credit_consumed(self, account_name: str): + usage = self.read_usage() + usage_key = account_name + for existing_key in usage: + if str(existing_key).casefold() == account_name.casefold(): + usage_key = existing_key + break + entry = usage.get(usage_key, {}) + if not isinstance(entry, dict): + entry = {} + remaining = optional_int(entry.get("rateLimitResetCreditsRemaining")) + if remaining is not None: + entry["rateLimitResetCreditsRemaining"] = max(0, remaining - 1) + usage[usage_key] = entry + write_json_object_atomic(self.usage_path, usage) + def ensure_plugin_connectivity(self) -> dict: status = { "source": str(self.mobile_codex_config_path), @@ -4504,7 +4596,8 @@ def do_POST(self): return if len(parts) == 3 and parts[0] == "api" and parts[1] == "accounts" and parts[2] == "rate-limit-reset": - snapshot = self.state().consume_rate_limit_reset_credit() + body = decode_body(self) + snapshot = self.state().consume_rate_limit_reset_credit(str(body.get("name", ""))) json_response(self, 200, snapshot) return diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 30c801e2..40a882cb 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1262,6 +1262,53 @@ def test_consume_rate_limit_reset_credit_calls_app_server_and_returns_status(sel finally: gateway.DEFAULT_SWITCHER_HOME = old_home + def test_consume_rate_limit_reset_credit_for_named_account_uses_profile_auth_without_switching(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + codex_home = tmp_path / "codex" + switcher_home = tmp_path / "switcher" + accounts_dir = switcher_home / "accounts" + codex_home.mkdir() + (accounts_dir / "Main").mkdir(parents=True) + (accounts_dir / "Free").mkdir(parents=True) + (accounts_dir / "Main" / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "main-token"}}), + encoding="utf-8", + ) + (accounts_dir / "Free" / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "free-token"}}), + encoding="utf-8", + ) + (codex_home / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "main-token"}}), + encoding="utf-8", + ) + (switcher_home / "active-account.txt").write_text("Main\n", encoding="utf-8") + (switcher_home / "usage.json").write_text(json.dumps({ + "Free": {"rateLimitResetCreditsRemaining": 2} + }), encoding="utf-8") + + old_home = gateway.DEFAULT_SWITCHER_HOME + gateway.DEFAULT_SWITCHER_HOME = switcher_home + try: + state = GatewayState(codex_home, "token", Path("/missing-codex"), False) + + with mock.patch("codex_phone_gateway.uuid.uuid4", return_value="reset-id"), \ + mock.patch("codex_phone_gateway.consume_codex_rate_limit_reset_credit", return_value={"consumed": True}) as consume: + snapshot = state.consume_rate_limit_reset_credit("Free") + + consume.assert_called_once_with( + "free-token", + "codepilot-rate-limit-reset-Free-reset-id", + ) + self.assertEqual(snapshot["activeAccount"], "Main") + statuses = {account["name"]: account for account in snapshot["accounts"]} + self.assertEqual(statuses["Free"]["rateLimitResetCreditsRemaining"], 1) + self.assertEqual(snapshot["rateLimitReset"]["accountName"], "Free") + self.assertEqual((switcher_home / "active-account.txt").read_text(encoding="utf-8"), "Main\n") + finally: + 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/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index a50297de..b26ad6d0 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -2235,6 +2235,7 @@ struct AccountStatusView: View { @State private var refreshTargetAccount: AccountUsageStatus? @State private var reconnectTargetServer: MCPServerStatus? @State private var isAddingAccount = false + @State private var rateLimitResetTargetAccount: AccountUsageStatus? @State private var showRateLimitResetConfirmation = false var body: some View { @@ -2247,25 +2248,6 @@ struct AccountStatusView: View { } } - Section { - Button { - showRateLimitResetConfirmation = true - } label: { - HStack { - Label("Use Reset Credit", systemImage: "arrow.counterclockwise.circle") - Spacer() - if model.isResettingRateLimit { - ProgressView() - } - } - } - .disabled(model.activeAccount.isEmpty || model.isResettingRateLimit) - } header: { - Text("Rate Limit Reset") - } footer: { - Text("Uses one Codex reset credit for the active account, if one is available, then refreshes usage.") - } - Section { if model.isLoadingAccountStatus && model.accountStatuses.isEmpty { HStack { @@ -2280,13 +2262,17 @@ struct AccountStatusView: View { AccountStatusRow( account: account, switchingAccountName: model.switchingAccountName, - refreshingAuthAccountName: model.refreshingAuthAccountName + refreshingAuthAccountName: model.refreshingAuthAccountName, + resettingRateLimitAccountName: model.resettingRateLimitAccountName ) { Task { await model.switchAccount(named: account.name, baseURL: gatewayURL, token: gatewayToken) } } onRefreshAuth: { refreshTargetAccount = account + } onResetRateLimit: { + rateLimitResetTargetAccount = account + showRateLimitResetConfirmation = true } } } @@ -2334,18 +2320,21 @@ struct AccountStatusView: View { await model.pollAccountStatus(baseURL: gatewayURL, token: gatewayToken) } .confirmationDialog( - "Use a reset credit for \(displayValue(model.activeAccount))?", + "Use a reset credit for \(displayValue(rateLimitResetTargetAccount?.name ?? ""))?", isPresented: $showRateLimitResetConfirmation, titleVisibility: .visible ) { Button("Use Reset Credit") { + let account = rateLimitResetTargetAccount Task { - await model.resetActiveAccountRateLimit(baseURL: gatewayURL, token: gatewayToken) + if let account { + await model.resetRateLimit(for: account.name, baseURL: gatewayURL, token: gatewayToken) + } } } Button("Cancel", role: .cancel) {} } message: { - Text("This asks Codex to consume one available rate limit reset credit on the currently active account.") + Text("This asks Codex to consume one available rate limit reset credit on that account, without switching accounts.") } .sheet(item: $refreshTargetAccount) { account in AccountAuthRefreshSheet( @@ -2628,21 +2617,27 @@ struct AccountStatusRow: View { let account: AccountUsageStatus let switchingAccountName: String? let refreshingAuthAccountName: String? + let resettingRateLimitAccountName: String? let onSwitch: (() -> Void)? let onRefreshAuth: (() -> Void)? + let onResetRateLimit: (() -> Void)? init( account: AccountUsageStatus, switchingAccountName: String? = nil, refreshingAuthAccountName: String? = nil, + resettingRateLimitAccountName: String? = nil, onSwitch: (() -> Void)? = nil, - onRefreshAuth: (() -> Void)? = nil + onRefreshAuth: (() -> Void)? = nil, + onResetRateLimit: (() -> Void)? = nil ) { self.account = account self.switchingAccountName = switchingAccountName self.refreshingAuthAccountName = refreshingAuthAccountName + self.resettingRateLimitAccountName = resettingRateLimitAccountName self.onSwitch = onSwitch self.onRefreshAuth = onRefreshAuth + self.onResetRateLimit = onResetRateLimit } var body: some View { @@ -2672,6 +2667,22 @@ struct AccountStatusRow: View { .disabled(refreshingAuthAccountName != nil) .accessibilityLabel("Refresh login for \(account.name)") } + if let onResetRateLimit, (account.rateLimitResetCreditsRemaining ?? 0) > 0 { + Button { + onResetRateLimit() + } label: { + if resettingRateLimitAccountName == account.name { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.counterclockwise.circle") + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(resettingRateLimitAccountName != nil) + .accessibilityLabel("Use reset credit for \(account.name)") + } if let onSwitch, !account.isActive { Button { onSwitch() @@ -3729,7 +3740,7 @@ final class CodexPhoneModel: ObservableObject { @Published var switchingAccountName: String? @Published var refreshingAuthAccountName: String? @Published var addingAccountName: String? - @Published var isResettingRateLimit = false + @Published var resettingRateLimitAccountName: String? @Published var errorMessage: String? private var pollingJobIDs: Set = [] @@ -3871,17 +3882,18 @@ final class CodexPhoneModel: ObservableObject { } } - func resetActiveAccountRateLimit(baseURL: String, token: String) async -> Bool { + func resetRateLimit(for name: String, baseURL: String, token: String) async -> Bool { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } - guard !isResettingRateLimit else { return false } - isResettingRateLimit = true - defer { isResettingRateLimit = false } + guard !trimmedName.isEmpty else { return false } + guard resettingRateLimitAccountName == nil else { return false } + resettingRateLimitAccountName = trimmedName + defer { resettingRateLimitAccountName = nil } do { - let response = try await client.consumeRateLimitResetCredit(baseURL: baseURL, token: token) + let response = try await client.consumeRateLimitResetCredit(accountName: trimmedName, baseURL: baseURL, token: token) applyAccountStatus(response) await flushQueuedPromptsIfPossible(baseURL: baseURL, token: token) - let accountName = activeAccount.isEmpty ? "the active account" : activeAccount - errorMessage = "Rate limit reset requested for \(accountName)." + errorMessage = "Rate limit reset requested for \(trimmedName)." return true } catch { report(error) @@ -4585,11 +4597,11 @@ struct CodexGatewayClient { ) } - func consumeRateLimitResetCredit(baseURL: String, token: String) async throws -> AccountStatusResponse { + func consumeRateLimitResetCredit(accountName: String, baseURL: String, token: String) async throws -> AccountStatusResponse { try await request( "/api/accounts/rate-limit-reset", method: "POST", - body: Optional<[String: String]>.none, + body: RateLimitResetRequest(name: accountName), baseURL: baseURL, token: token ) @@ -4949,6 +4961,10 @@ struct SwitchAccountRequest: Encodable { let name: String } +struct RateLimitResetRequest: Encodable { + let name: String +} + struct RefreshAccountAuthRequest: Encodable { let name: String let accessToken: String From 3d93620f32b854833eaa8d18f86d45fadeb26a8a Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 5 Jul 2026 07:19:29 +0200 Subject: [PATCH 14/57] Fix per-account reset and manual switch hold --- Sources/CodexAccountSwitcher/main.swift | 3 + gateway/codex_phone_gateway.py | 93 +++++++++++++++++++++++-- gateway/test_codex_phone_gateway.py | 40 ++++++----- 3 files changed, 114 insertions(+), 22 deletions(-) diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index a361d943..f2cac762 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -1289,6 +1289,9 @@ final class CodexAccountSwitcher { try next.name.write(to: settings.activeAccountMarker, atomically: true, encoding: .utf8) recordSwitch(to: next.name, isAutomatic: isAutomatic) + if !isAutomatic { + clearPendingSwitch() + } let relaunch = relaunchCodexAppIfRunning() switch relaunch { case .relaunched: diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 3dbf5e50..714c9f18 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -2563,11 +2563,7 @@ def consume_rate_limit_reset_credit(self, raw_name: str = "") -> dict: requested_name = str(raw_name or "").strip() if requested_name: account_name = self.resolve_account_profile_name(requested_name) - auth_path = self.accounts_dir / account_name / "auth.json" - result = consume_codex_rate_limit_reset_credit( - self.access_token_from_auth_file(auth_path), - f"codepilot-rate-limit-reset-{account_name}-{uuid.uuid4()}", - ) + result = self.consume_rate_limit_reset_credit_for_account(account_name) self.record_rate_limit_reset_credit_consumed(account_name) snapshot = self.account_status_snapshot() snapshot["rateLimitReset"] = { @@ -2586,6 +2582,93 @@ def consume_rate_limit_reset_credit(self, raw_name: str = "") -> dict: snapshot["rateLimitReset"] = result return snapshot + def consume_rate_limit_reset_credit_for_account(self, account_name: str) -> dict: + target_auth = self.accounts_dir / account_name / "auth.json" + if not target_auth.is_file(): + raise LookupError(f"No auth profile named {account_name}") + + previous_marker = None + previous_auth = None + if self.active_account_marker.is_file(): + previous_marker = self.active_account_marker.read_text(encoding="utf-8") + if self.active_auth_path.is_file(): + previous_auth = self.active_auth_path.read_bytes() + + previous_account = self.active_account_name() + needs_restore = account_name != previous_account or target_auth.read_bytes() != (previous_auth or b"") + idempotency_key = f"codepilot-rate-limit-reset-{account_name}-{uuid.uuid4()}" + + with RUN_LOCK: + if needs_restore and self.has_running_jobs(): + raise RuntimeError("Cannot reset a different account while a Codex turn is running") + + try: + if needs_restore: + self.install_account_for_app_server_context(account_name, target_auth) + return self.consume_active_app_server_rate_limit_reset_credit(idempotency_key) + finally: + if needs_restore: + self.restore_app_server_account_context(previous_auth, previous_marker) + + def consume_active_app_server_rate_limit_reset_credit(self, idempotency_key: str) -> dict: + client = self.app_server_client() + client.start() + return client.request("account/rateLimitResetCredit/consume", { + "creditType": "usage_limit", + "idempotencyKey": idempotency_key, + }) + + def install_account_for_app_server_context(self, account_name: str, profile_auth: Path): + with self._app_server_lock: + self._close_app_server_client_locked() + 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") + + def restore_app_server_account_context(self, previous_auth: bytes | None, previous_marker: str | None): + with self._app_server_lock: + self._close_app_server_client_locked() + with self.auth_file_lock(): + if previous_auth is None: + try: + self.active_auth_path.unlink() + except FileNotFoundError: + pass + else: + self.write_active_auth_bytes(previous_auth) + + if previous_marker is None: + try: + self.active_account_marker.unlink() + except FileNotFoundError: + pass + else: + self.active_account_marker.parent.mkdir(parents=True, exist_ok=True) + self.active_account_marker.write_text(previous_marker, encoding="utf-8") + + def write_active_auth_bytes(self, payload: bytes): + self.active_auth_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=".auth.json.codex-phone-restore.", + suffix=".tmp", + dir=str(self.active_auth_path.parent), + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(payload) + os.chmod(tmp_path, 0o600) + tmp_path.replace(self.active_auth_path) + os.chmod(self.active_auth_path, 0o600) + except Exception: + try: + tmp_path.unlink() + except OSError: + pass + raise + def access_token_from_auth_file(self, auth_path: Path) -> str: try: payload = json.loads(auth_path.read_text(encoding="utf-8")) diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 40a882cb..d8dfd1c6 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -37,6 +37,7 @@ class RecordingRateLimitResetAppServerClient: def __init__(self): self.started = False self.requests = [] + self.closed = False def start(self): self.started = True @@ -46,6 +47,9 @@ def request(self, method, params=None): self.requests.append((method, params or {})) return {"consumed": True} + def close(self): + self.closed = True + class RecordingPushNotifier: def __init__(self): @@ -1262,7 +1266,7 @@ def test_consume_rate_limit_reset_credit_calls_app_server_and_returns_status(sel finally: gateway.DEFAULT_SWITCHER_HOME = old_home - def test_consume_rate_limit_reset_credit_for_named_account_uses_profile_auth_without_switching(self): + def test_consume_rate_limit_reset_credit_for_named_account_temporarily_switches_and_restores(self): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) codex_home = tmp_path / "codex" @@ -1271,18 +1275,11 @@ def test_consume_rate_limit_reset_credit_for_named_account_uses_profile_auth_wit codex_home.mkdir() (accounts_dir / "Main").mkdir(parents=True) (accounts_dir / "Free").mkdir(parents=True) - (accounts_dir / "Main" / "auth.json").write_text( - json.dumps({"tokens": {"access_token": "main-token"}}), - encoding="utf-8", - ) - (accounts_dir / "Free" / "auth.json").write_text( - json.dumps({"tokens": {"access_token": "free-token"}}), - encoding="utf-8", - ) - (codex_home / "auth.json").write_text( - json.dumps({"tokens": {"access_token": "main-token"}}), - encoding="utf-8", - ) + main_auth = json.dumps({"tokens": {"account_id": "main", "access_token": "main-token"}}) + free_auth = json.dumps({"tokens": {"account_id": "free", "access_token": "free-token"}}) + (accounts_dir / "Main" / "auth.json").write_text(main_auth, encoding="utf-8") + (accounts_dir / "Free" / "auth.json").write_text(free_auth, encoding="utf-8") + (codex_home / "auth.json").write_text(main_auth, encoding="utf-8") (switcher_home / "active-account.txt").write_text("Main\n", encoding="utf-8") (switcher_home / "usage.json").write_text(json.dumps({ "Free": {"rateLimitResetCreditsRemaining": 2} @@ -1292,20 +1289,29 @@ def test_consume_rate_limit_reset_credit_for_named_account_uses_profile_auth_wit gateway.DEFAULT_SWITCHER_HOME = switcher_home try: state = GatewayState(codex_home, "token", Path("/missing-codex"), False) + client = RecordingRateLimitResetAppServerClient() with mock.patch("codex_phone_gateway.uuid.uuid4", return_value="reset-id"), \ - mock.patch("codex_phone_gateway.consume_codex_rate_limit_reset_credit", return_value={"consumed": True}) as consume: + mock.patch.object(state, "app_server_client", return_value=client): snapshot = state.consume_rate_limit_reset_credit("Free") - consume.assert_called_once_with( - "free-token", - "codepilot-rate-limit-reset-Free-reset-id", + self.assertTrue(client.started) + self.assertEqual( + client.requests, + [( + "account/rateLimitResetCredit/consume", + { + "creditType": "usage_limit", + "idempotencyKey": "codepilot-rate-limit-reset-Free-reset-id", + }, + )], ) self.assertEqual(snapshot["activeAccount"], "Main") statuses = {account["name"]: account for account in snapshot["accounts"]} self.assertEqual(statuses["Free"]["rateLimitResetCreditsRemaining"], 1) self.assertEqual(snapshot["rateLimitReset"]["accountName"], "Free") self.assertEqual((switcher_home / "active-account.txt").read_text(encoding="utf-8"), "Main\n") + self.assertEqual((codex_home / "auth.json").read_text(encoding="utf-8"), main_auth) finally: gateway.DEFAULT_SWITCHER_HOME = old_home From b56973aca993ea009855d118b64328713a3a6933 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 6 Jul 2026 07:10:35 +0200 Subject: [PATCH 15/57] Use limiting window for total credit --- gateway/codex_phone_gateway.py | 74 +++++++++++++++---- gateway/test_codex_phone_gateway.py | 26 +++++++ ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 59 ++++++++++++--- .../TotalCreditActivityTests.swift | 34 ++++++++- 4 files changed, 162 insertions(+), 31 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 714c9f18..77d2c509 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -3081,22 +3081,9 @@ def live_activity_content_state(self, snapshot: dict) -> dict: accounts = [item for item in snapshot.get("accounts", []) if isinstance(item, dict)] buckets = [] for account in accounts: - if account.get("fiveHourRemainingPercent") is not None or account.get("fiveHourResetsAt") is not None: - buckets.append({ - "remaining": account.get("fiveHourRemainingPercent"), - "resetAt": account.get("fiveHourResetsAt"), - "windowMins": account.get("fiveHourWindowMins"), - "label": "5h", - "authStale": bool(account.get("authStale")), - }) - elif account.get("weeklyRemainingPercent") is not None or account.get("weeklyResetsAt") is not None: - buckets.append({ - "remaining": account.get("weeklyRemainingPercent"), - "resetAt": account.get("weeklyResetsAt"), - "windowMins": account.get("weeklyWindowMins"), - "label": "weekly", - "authStale": bool(account.get("authStale")), - }) + bucket = self.account_credit_bucket(account) + if bucket is not None: + buckets.append(bucket) available = [item for item in buckets if not item["authStale"]] reported = [item for item in available if item["remaining"] is not None] remaining = [max(0, min(100, int(item["remaining"]))) for item in reported] @@ -3139,6 +3126,61 @@ def live_activity_content_state(self, snapshot: dict) -> dict: "generatedAt": generated_at, } + def account_credit_bucket(self, account: dict) -> dict | None: + windows = [] + if account.get("fiveHourRemainingPercent") is not None or account.get("fiveHourResetsAt") is not None: + windows.append({ + "remaining": account.get("fiveHourRemainingPercent"), + "resetAt": account.get("fiveHourResetsAt"), + "windowMins": account.get("fiveHourWindowMins"), + "label": "5h", + }) + if account.get("weeklyRemainingPercent") is not None or account.get("weeklyResetsAt") is not None: + windows.append({ + "remaining": account.get("weeklyRemainingPercent"), + "resetAt": account.get("weeklyResetsAt"), + "windowMins": account.get("weeklyWindowMins"), + "label": "weekly", + }) + if not windows: + return None + + known_remaining = [ + max(0, min(100, int(window["remaining"]))) + for window in windows + if window.get("remaining") is not None + ] + effective_remaining = min(known_remaining) if known_remaining else None + label = str(windows[0].get("label") or "credit") + reset_at = None + window_mins = windows[0].get("windowMins") + + if effective_remaining is not None and effective_remaining > 0: + for window in windows: + if window.get("remaining") is not None and max(0, min(100, int(window["remaining"]))) == effective_remaining: + label = str(window.get("label") or label) + window_mins = window.get("windowMins") + break + else: + depleted = [ + window for window in windows + if window.get("remaining") is not None + and int(window.get("remaining") or 0) <= 0 + and window.get("resetAt") is not None + ] + limiting = max(depleted, key=lambda item: int(item.get("resetAt") or 0), default=windows[0]) + label = str(limiting.get("label") or label) + reset_at = limiting.get("resetAt") + window_mins = limiting.get("windowMins") + + return { + "remaining": effective_remaining, + "resetAt": reset_at, + "windowMins": window_mins, + "label": label, + "authStale": bool(account.get("authStale")), + } + def publish_live_activity_state(self, snapshot: dict): registrations = self.read_live_activities() if not registrations: diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index d8dfd1c6..e80280b6 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1579,6 +1579,32 @@ def test_unchanged_live_activity_state_is_not_pushed_twice(self): finally: gateway.DEFAULT_SWITCHER_HOME = old_home + def test_live_activity_uses_limiting_account_window(self): + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + now = 1_800_000_000 + weekly_reset = now + 604_800 + + content = state.live_activity_content_state({ + "generatedAt": now, + "accounts": [{ + "name": "Main", + "fiveHourRemainingPercent": 99, + "fiveHourResetsAt": now + 3_600, + "fiveHourWindowMins": 300, + "weeklyRemainingPercent": 0, + "weeklyResetsAt": weekly_reset, + "weeklyWindowMins": 10_080, + "authStale": False, + }], + }) + + self.assertEqual(content["kind"], "refilling") + self.assertIsNone(content["percent"]) + self.assertEqual(content["progress"], 0) + self.assertEqual(content["usableAccountCount"], 0) + self.assertEqual(content["nextRefreshAt"], weekly_reset) + self.assertEqual(content["refreshLabel"], "weekly") + def test_apns_notifier_uses_certificate_credentials_from_environment(self): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index b26ad6d0..fd7e47b4 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -2951,22 +2951,59 @@ struct AccountCreditBucket { init?(account: AccountUsageStatus) { accountName = account.name authStale = account.authStale - if account.fiveHourRemainingPercent != nil || account.fiveHourResetsAt != nil { - label = "5h" - remainingPercent = account.fiveHourRemainingPercent - resetAt = account.fiveHourResetsAt - windowMins = account.fiveHourWindowMins - } else if account.weeklyRemainingPercent != nil || account.weeklyResetsAt != nil { - label = "weekly" - remainingPercent = account.weeklyRemainingPercent - resetAt = account.weeklyResetsAt - windowMins = account.weeklyWindowMins - } else { + + let windows = [ + CreditWindow( + label: "5h", + remainingPercent: account.fiveHourRemainingPercent, + resetAt: account.fiveHourResetsAt, + windowMins: account.fiveHourWindowMins + ), + CreditWindow( + label: "weekly", + remainingPercent: account.weeklyRemainingPercent, + resetAt: account.weeklyResetsAt, + windowMins: account.weeklyWindowMins + ) + ].filter { $0.remainingPercent != nil || $0.resetAt != nil } + + guard !windows.isEmpty else { return nil } + + let knownRemaining = windows.compactMap(\.remainingPercent).map { max(0, min(100, $0)) } + remainingPercent = knownRemaining.min() + + if let effectiveRemaining = remainingPercent, effectiveRemaining > 0 { + let displayWindow = windows.first { $0.remainingPercent == effectiveRemaining } + ?? windows.first + label = displayWindow?.label ?? "credit" + resetAt = nil + windowMins = displayWindow?.windowMins + return + } + + let depletedWindows = windows.filter { window in + guard let remaining = window.remainingPercent else { return false } + return remaining <= 0 && window.resetAt != nil + } + let limitingWindow = depletedWindows.max { left, right in + (left.resetAt ?? 0) < (right.resetAt ?? 0) + } ?? windows.first + + label = limitingWindow?.label ?? "credit" + resetAt = limitingWindow?.resetAt + windowMins = limitingWindow?.windowMins } } +private struct CreditWindow { + let label: String + let remainingPercent: Int? + let resetAt: Int? + let windowMins: Int? +} + struct SignupURL: Identifiable { let id: String let url: URL diff --git a/ios/CodexPhone/CodexPhoneTests/TotalCreditActivityTests.swift b/ios/CodexPhone/CodexPhoneTests/TotalCreditActivityTests.swift index a83363db..c9285cb7 100644 --- a/ios/CodexPhone/CodexPhoneTests/TotalCreditActivityTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/TotalCreditActivityTests.swift @@ -33,6 +33,29 @@ final class TotalCreditActivityTests: XCTestCase { XCTAssertEqual(state.refreshLabel, "5h") } + func testWeeklyExhaustionLimitsFiveHourCredit() { + let weeklyResetAt = Int(now.timeIntervalSince1970) + 604_800 + let state = TotalCreditStatus( + accounts: [ + account( + name: "One", + remaining: 99, + resetAt: Int(now.timeIntervalSince1970) + 3_600, + weeklyRemaining: 0, + weeklyResetAt: weeklyResetAt + ) + ], + now: now + ).activityState + + XCTAssertEqual(state.kind, .refilling) + XCTAssertNil(state.percent) + XCTAssertEqual(state.progress, 0, accuracy: 0.001) + XCTAssertEqual(state.usableAccountCount, 0) + XCTAssertEqual(state.nextRefreshAt, weeklyResetAt) + XCTAssertEqual(state.refreshLabel, "weekly") + } + func testAllStaleAccountsRequireAuthentication() { let state = TotalCreditStatus( accounts: [account(name: "Private Account", remaining: 80, authStale: true)], @@ -99,6 +122,8 @@ final class TotalCreditActivityTests: XCTestCase { name: String, remaining: Int?, resetAt: Int? = nil, + weeklyRemaining: Int? = nil, + weeklyResetAt: Int? = nil, authStale: Bool = false ) -> AccountUsageStatus { AccountUsageStatus( @@ -108,10 +133,11 @@ final class TotalCreditActivityTests: XCTestCase { fiveHourUsedPercent: remaining.map { 100 - $0 }, fiveHourWindowMins: 300, fiveHourResetsAt: resetAt, - weeklyRemainingPercent: nil, - weeklyUsedPercent: nil, - weeklyWindowMins: nil, - weeklyResetsAt: nil, + weeklyRemainingPercent: weeklyRemaining, + weeklyUsedPercent: weeklyRemaining.map { 100 - $0 }, + weeklyWindowMins: weeklyRemaining == nil && weeklyResetAt == nil ? nil : 10_080, + weeklyResetsAt: weeklyResetAt, + rateLimitResetCreditsRemaining: nil, lastRefreshAt: nil, lastUsedAt: nil, lastLimitAt: nil, From 52827f53f7f9c992184c38c0f8e59bbdf49d482d Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 6 Jul 2026 07:24:23 +0200 Subject: [PATCH 16/57] Record launch decision approvals --- ops/agents/escalations/presence-maintenance.md | 14 ++++++++------ ops/agents/escalations/security-scan.md | 8 +++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/ops/agents/escalations/presence-maintenance.md b/ops/agents/escalations/presence-maintenance.md index 1f6b78f6..775c502e 100644 --- a/ops/agents/escalations/presence-maintenance.md +++ b/ops/agents/escalations/presence-maintenance.md @@ -1,9 +1,11 @@ # Presence Maintenance Escalation -Approval needed before public publishing: +Approval status: approved by maintainer on 2026-07-06. -- Confirm beta privacy copy in `docs/PRIVACY.md`. -- Confirm public positioning in `README.md`, `docs/index.md`, and `docs/FAQ.md`. -- Review `docs/APP_STORE_METADATA_DRAFT.md` before any TestFlight/App Store metadata is entered publicly. -- Enable or confirm GitHub Pages source for the repository, preferably the `docs/` folder. -- Approve sanitized screenshot capture before screenshots are committed or used in App Store/TestFlight metadata. +The following public-publishing decisions are approved: + +- Beta privacy copy in `docs/PRIVACY.md` once that tracked file exists. +- Public positioning in `README.md`, `docs/index.md`, and `docs/FAQ.md`. +- TestFlight/App Store metadata from `docs/APP_STORE_METADATA_DRAFT.md`. +- GitHub Pages source for the repository, preferably the `docs/` folder. +- Sanitized screenshot capture before screenshots are committed or used in App Store/TestFlight metadata. diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 2eb05a5e..5ff2158a 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -1,18 +1,20 @@ # CodePilot Security Scan Escalation -Maintainer intervention is needed before public launch for these product-boundary decisions: +Maintainer decision status: approved by maintainer on 2026-07-06. + +Maintainer intervention was requested before public launch for these product-boundary decisions: 1. Authenticated arbitrary file download remains enabled. - Surface: `gateway/codex_phone_gateway.py` exposes `/api/files/download?path=...`. - Current behavior: any client with the gateway bearer token can request any absolute file path readable by the gateway process. - Risk: a leaked or phished gateway token becomes direct local file disclosure, independent of Codex approval/sandbox controls. - - Decision needed: define the allowed file scope for iPhone previews, such as uploaded files, explicit per-thread workspace roots, or short-lived file-preview grants. + - Decision: approved to proceed with the proposed hardening direction. Keep the feature, but narrow the allowed scope before public launch to uploaded files, explicit per-thread workspace roots, or short-lived file-preview grants. 2. Localhost proxy sessions are bearerless capability URLs. - Surface: `POST /api/local-web/sessions` requires the bearer token, but the returned `/api/local-web//...` URL is fetched without bearer auth. - Current behavior: the random session id gates access for the session lifetime. - Risk: anyone who obtains a live session URL can proxy GET requests to the selected loopback port through the public gateway until expiry. - - Decision needed: decide whether this is acceptable for WebView compatibility, should have a shorter lifetime, or should move to an authenticated browser/proxy design. + - Decision: approved to proceed with the proposed hardening direction. Keep WebView compatibility, but shorten and constrain capability sessions before public launch unless an authenticated browser/proxy design is ready. Local hardening already applied in this branch: From a962065e0454dcb701e1787e5a0b95ac0991b85e Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 6 Jul 2026 07:25:10 +0200 Subject: [PATCH 17/57] Refresh agent worktrees before runs --- scripts/codepilot-agent-runner.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/codepilot-agent-runner.sh b/scripts/codepilot-agent-runner.sh index b6504e52..42b5d6b9 100755 --- a/scripts/codepilot-agent-runner.sh +++ b/scripts/codepilot-agent-runner.sh @@ -71,6 +71,17 @@ if [[ -d "$WORKTREE/.git" || -f "$WORKTREE/.git" ]]; then cd "$WORKTREE" fi +git fetch --quiet origin main 2>/dev/null || true +if git diff --quiet && git diff --cached --quiet; then + if git merge-base --is-ancestor HEAD origin/main 2>/dev/null; then + git merge --ff-only --quiet origin/main + else + echo "Agent worktree has commits not on origin/main; leaving branch as-is." + fi +else + echo "Agent worktree has local changes; skipping origin/main refresh." +fi + REAL_GIT="$(command -v git)" REAL_GH="$(command -v gh 2>/dev/null || true)" export CODEPILOT_AGENT_REAL_GIT="$REAL_GIT" From 0caee3e8be1167ebe9e2be2f6b52c44a755d4271 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Wed, 8 Jul 2026 05:58:12 +0200 Subject: [PATCH 18/57] Add timeout guard to agent runner --- scripts/codepilot-agent-runner.sh | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/codepilot-agent-runner.sh b/scripts/codepilot-agent-runner.sh index 42b5d6b9..f60a5420 100755 --- a/scripts/codepilot-agent-runner.sh +++ b/scripts/codepilot-agent-runner.sh @@ -10,6 +10,7 @@ WORKTREE_ROOT="$STATE_DIR/worktrees" THREAD_ID_FILE="$STATE_DIR/thread-id" CODEX_BIN="${CODEPILOT_CODEX_BIN:-codex}" CODEX_MODEL="${CODEPILOT_AGENT_MODEL:-}" +AGENT_TIMEOUT_SECONDS="${CODEPILOT_AGENT_TIMEOUT_SECONDS:-7200}" GUARD_BIN="$ROOT/scripts/agent-guard-bin" PUBLIC_AUTONOMY="${CODEPILOT_AGENT_PUBLIC_AUTONOMY:-launch}" @@ -148,15 +149,34 @@ escalation only when maintainer intervention is genuinely required. EOF ) +prompt_file="$(mktemp "${TMPDIR:-/tmp}/codepilot-agent-prompt.XXXXXX")" +trap 'rm -f "$prompt_file" 2>/dev/null || true; rm -rf "$LOCK_DIR" 2>/dev/null || true' EXIT + { printf '%s\n\n' "$PUBLIC_WRITE_POLICY" cat "$PROMPT" -} | "$CODEX_BIN" exec \ +} > "$prompt_file" + +"$CODEX_BIN" exec \ --cd "$PWD" \ "${model_args[@]}" \ --config "model_reasoning_effort=\"$REASONING_EFFORT\"" \ --sandbox danger-full-access \ - - + - < "$prompt_file" & +codex_pid="$!" +deadline=$((SECONDS + AGENT_TIMEOUT_SECONDS)) +while kill -0 "$codex_pid" 2>/dev/null; do + if (( SECONDS >= deadline )); then + echo "CodePilot agent timed out after ${AGENT_TIMEOUT_SECONDS}s: $JOB" >&2 + kill -TERM "$codex_pid" 2>/dev/null || true + sleep 10 + kill -KILL "$codex_pid" 2>/dev/null || true + wait "$codex_pid" 2>/dev/null || true + exit 124 + fi + sleep 5 +done +wait "$codex_pid" fi From 324a99153f9b01cd33bf549167021c6a6602420e Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 13 Jul 2026 08:00:37 +0200 Subject: [PATCH 19/57] Draft beta community outreach packet --- .../2026-07-01-codepilot-beta-drafts.md | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md new file mode 100644 index 00000000..1f854a6a --- /dev/null +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -0,0 +1,417 @@ +# CodePilot Community Drafts - 2026-07-01 + +Status: draft only. Do not post publicly without explicit maintainer approval. + +Last rule review: 2026-07-13. + +Identity: `codepilotios` + +Scope: +- Transparent beta-focused community promotion for CodePilot. +- No account creation. +- No public posting. +- No paid subscription, pricing, or future commercial claims. +- No private hostnames, tokens, personal emails, or unreleased links. + +## Baseline Reddit Rules + +Reddit-wide rules require users to follow community rules, participate authentically, avoid spam/content manipulation, and avoid deceptive identity use: + +Reddit Help says promotional content is not automatically spam, but communities may ban promotion or apply a 10% self-promotion rule: + +Legacy Reddit self-promotion guidance still captures the expected culture: do not only submit your own links, do not ask for votes, disclose affiliation, and message moderators when unsure: + +Operational rules for this queue: +- Use the `codepilotios` identity only after a maintainer creates or approves it. +- Always disclose: "I am the maker / creator of CodePilot." +- Post at most one CodePilot promotion per community unless the community explicitly has a recurring self-promotion thread. +- Never ask for upvotes. +- Prefer comments in approved self-promotion or feedback threads before standalone posts. +- If a subreddit rule is ambiguous, ask moderators before posting. + +## Communities Researched + +| Community | Fit | Rule summary | Recommendation | +| --- | --- | --- | --- | +| `r/ChatGPTCoding` | High | Self-promotion thread permits project sharing, with rules including no selling model access and one promotion per project. Karma filters are active. Source: | Best first Reddit placement. Use the self-promotion thread only. Human approval before posting. | +| `r/codex` | High | Community is specifically for OpenAI Codex tools. Search-visible rules require posts to be directly related to Codex and avoid low-effort rants. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. Use text-only feedback framing unless mods approve a beta link. | +| `r/SideProject` | Medium-high | Community is explicitly for sharing and receiving feedback on side projects. Current posts show beta/product feedback asks are common. Source: | Good standalone beta feedback post. Keep it story/feedback-first, not a launch blast. | +| `r/iOSProgramming` | Medium | App posts are allowed only on Saturday, one app post per year, and should not come from accounts with no previous activity. App Saturday requires sections for tech stack, challenge/solution, and AI disclosure. Sources: and | Approval required. Hold until Saturday and until `codepilotios` has prior participation. | +| `r/swift` | Medium | Self-promotion is tolerated only for active contributors; fewer than 5 posts/comments in the sub or account age under 2 months means no self-promotion. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after account age/activity threshold is met. | +| `r/macapps` | Medium | Main feed promotion requires qualification, 10 local karma, official distribution links only, no redirects/referrals, 30-day promotion spacing, relationship disclosure, and monthly megathread fallback. Recent megathreads require PCP format, including pricing amounts and a link. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | +| `r/OpenAI` | Medium-low | Search-visible rule text says self-promotional direct link posts to projects are not allowed and points to self-promo guidance. Source: | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | +| `r/MacOS` | Low for now | Mod post says self-promotion is allowed only for apps available in the macOS App Store; otherwise use `r/macapps`. Source: | Do not post unless/until CodePilot Mac is in the macOS App Store or mods explicitly approve. | +| `r/selfhosted` | Low-medium later | Self-promotion is limited by Reddit self-promotion norms; promoted apps must be production-ready and have docs, and comments should add relevant value. Source: | Hold for now. CodePilot has a local gateway and optional user-owned tunnel, but this is not primarily a self-hosted server product and should not be promoted there until public docs and production readiness are stronger. | +| `r/ClaudeAI` | Low | Search-visible rules and moderation comments indicate self-promotion, solicitation, and AI-created promotional content are not allowed. Source: | Do not post CodePilot promotion. Only answer organically if directly relevant and approved by a maintainer. | +| Hacker News / Show HN | Medium | Show HN is for something people can try; HN asks submitters not to use it primarily for promotion and not to post generated or AI-edited comments. Sources: and | Human rewrite required. Prepare outline only; do not paste AI-generated text. | +| Product Hunt | Medium later | Product Hunt recommends clear product pages, maker first comment, feedback requests rather than upvote requests, and warns against spamming communities where you have not been active. Sources: and | Not a first beta move. Requires account, launch assets, and pricing/status decisions, so hold for human approval. | + +## Rules That Require Approval Before Posting + +- Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. +- `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply. +- `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, mandatory App Saturday content sections. +- `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit. +- `r/macapps`: requires 10 local karma and qualification for main feed; otherwise use monthly megathread. Official distribution links only. Current megathread PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. +- `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. +- `r/MacOS`: hold unless CodePilot Mac is in the macOS App Store or mods approve. +- `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; ask mods before any maker-authored mention. +- `r/ClaudeAI`: avoid promotional posting because rules disallow self-promotion/solicitation and AI-created promotional content. +- Hacker News: a maintainer should write the actual comment manually because HN asks users not to post generated or AI-edited comments. +- Product Hunt: hold until account, maker identity, screenshots, launch URL, and current beta/pricing status are approved. + +## Draft Reddit Profile + +Profile display name: + +```text +CodePilot iOS +``` + +Profile bio: + +```text +Creator-run beta account for CodePilot: a Mac + iPhone companion for running Codex sessions from a Mac you control. I am here to collect setup feedback from Codex, iOS, and Mac automation users. Please do not send tokens, private logs, or credentials. +``` + +Pinned profile post draft: + +```text +Hi, I am the maker of CodePilot. + +CodePilot is in beta. It connects a Mac menu bar app, a local token-protected gateway, and an iPhone companion so Codex users can check account/usage state, continue sessions from iPhone, upload files, and steer or stop active turns from a Mac they control. + +This account is for transparent beta feedback only. I will disclose my relationship whenever I mention CodePilot in a community. Please do not send credentials, bearer tokens, private logs, private hostnames, or files from your projects. If you try the beta and hit setup friction, sanitized steps and screenshots are the most useful feedback. +``` + +## Draft 1: `r/ChatGPTCoding` Self-Promotion Thread Comment + +Posting target: current or next `r/ChatGPTCoding` self-promotion thread. + +Approval status: needs maintainer approval before posting. + +Rule notes: +- Fits the self-promotion thread. +- Do not mention selling access to models. +- Promote once per project. +- Expect karma filtering if `codepilotios` is new. + +```text +Hi, I am the maker of CodePilot. + +It is a beta Mac + iPhone companion for people already running Codex on their own Mac. The Mac side watches local Codex account/usage state and runs a token-protected local gateway. The iPhone side connects to that gateway so you can check active account state, continue a session away from the desk, upload files, steer/stop an active turn, and optionally use a Cloudflare Tunnel for remote access. + +I am looking for a small number of beta testers who already use Codex CLI on macOS and are willing to give blunt setup feedback. The areas I most want tested are: + +- whether the Mac gateway setup is understandable +- whether the iPhone connection/token flow is clear +- whether remote session control from iPhone actually solves a real workflow problem +- what errors or recovery steps are still confusing + +This is not a paid-subscription announcement and I am not asking for upvotes. If this matches your workflow, reply here with what setup you use for Codex today, and I will share the approved beta link once it is ready for public distribution. +``` + +## Draft 2: `r/codex` Moderator-First Beta Feedback Post + +Posting target: `r/codex` + +Approval status: moderator approval and maintainer approval required before posting. Do not include a beta link unless moderators explicitly approve it. + +Rule notes: +- Must be directly related to OpenAI Codex tools. +- Keep it high-signal and feedback-focused. +- New-account subreddit karma restrictions may block comments/posts. +- Disclose maker relationship. + +Moderator note draft: + +```text +Hi mods, I am the maker of CodePilot, a beta Mac + iPhone companion for people who run Codex from a Mac they control. + +I saw that r/codex requires posts to be directly related to Codex. Would a text-only beta feedback post be acceptable if it focuses on Codex CLI workflow, local gateway security, and setup clarity rather than a launch link? + +I would disclose my relationship, avoid pricing language, avoid asking for votes, and only include a beta link if you explicitly approve that. +``` + +Discussion body if approved: + +```text +I am the maker of CodePilot, and I am looking for feedback from people who use Codex on macOS. + +The beta is built around a simple constraint: Codex stays on the Mac where the CLI session, auth files, account state, and project files already live. CodePilot adds a Mac menu bar app, a token-protected local gateway, and an iPhone client for status, files, notifications, and turn controls. + +Optional remote access can use a user-owned Cloudflare Tunnel, but the phone still has to authenticate to the Mac gateway. I am trying to keep the trust boundary explicit instead of turning this into a hosted Codex proxy. + +The feedback I am looking for: + +- Would an iPhone companion for Codex sessions solve a real workflow problem for you? +- What would you need to verify before trusting a local gateway that touches Codex session state? +- Which setup step would make you stop: gateway token, tunnel setup, iOS pairing, file upload, or remote turn control? +- What documentation would you expect before trying a beta? + +No pricing announcement and no upvote request. I am trying to make the beta safer and clearer before broader distribution. +``` + +## Draft 3: `r/SideProject` Standalone Feedback Post + +Posting target: `r/SideProject` + +Approval status: needs maintainer approval before posting. + +Title options: + +```text +I built an iPhone companion for running Codex sessions from my Mac +``` + +```text +Looking for feedback on CodePilot, a Mac + iPhone beta for Codex users +``` + +Body: + +```text +I am the maker of CodePilot, and I am looking for feedback from people who already use Codex on macOS. + +The problem I kept running into: coding-agent work is tied to the Mac where the CLI session, auth files, account state, and project files live. If I walk away, I still want to see whether a turn finished, check usage/account state, upload a file from my phone, or stop/steer the session without exposing the whole machine broadly. + +So I built a beta with three pieces: + +- a Mac menu bar app for local account/usage state and account switching +- a local token-protected Python gateway on the Mac +- an iPhone app that connects to the gateway for threads, files, status, and turn controls + +Remote access is optional through a user-owned Cloudflare Tunnel. The iOS app talks to the Mac gateway, not directly to provider services. + +I am not announcing pricing or asking for votes. I am trying to learn whether the setup is understandable and whether this solves a real problem for Codex users. + +If you use Codex CLI on macOS: what would make you trust or reject a setup like this? What would you want to see before installing a beta that touches local coding-agent credentials? +``` + +## Draft 4: `r/iOSProgramming` App Saturday Post + +Posting target: `r/iOSProgramming`, Saturday only. + +Approval status: needs maintainer approval before posting; do not post from a new or inactive account. + +Title: + +```text +I built a SwiftUI iPhone companion for controlling Codex sessions on my Mac +``` + +Body: + +```text +I am the maker of CodePilot, a TestFlight beta for people who run Codex on a Mac and want an iPhone companion for setup/status/session control. + +Tech Stack Used + +The iOS app is built with SwiftUI. It talks to a token-protected local Python gateway running on the user's Mac. The Mac app is a menu bar utility that watches local Codex account and usage state. Optional remote access uses a user-owned Cloudflare Tunnel, but the iOS app still authenticates to the Mac gateway with a bearer token. + +The iPhone client currently focuses on connection setup, gateway health, thread/session status, file uploads, usage/account status, notifications, and controls for steering or stopping active turns. + +Development Challenge + How You Solved It + +The hard part was making remote control feel useful without pretending the phone owns the coding-agent session. The source of truth still lives on the Mac: auth files, project files, active turns, and account state. + +The solution was to keep the iOS app as a gateway client rather than a direct provider client. The gateway exposes narrow endpoints for thread state, uploads, health, account status, and turn controls. The iOS app stores only the gateway URL and token locally, then treats every setup failure as a recoverable connection state instead of a generic network error. + +The biggest area where I want feedback is onboarding clarity. A beta tester has to understand what is local, what is remote, what the token protects, and why they should not paste private logs or gateway tokens into issue reports. + +AI Disclosure + +AI-assisted. Parts of the project planning, copy, and implementation have been assisted by coding agents. Security-sensitive flows, gateway exposure, auth files, and release materials still need human review before broader release. + +I am looking for feedback from iOS/macOS developers who use coding agents: does this architecture feel understandable and reviewable? What would you need to see before trusting a beta that bridges an iPhone to a local Mac coding-agent gateway? +``` + +## Draft 5: `r/swift` Technical Discussion Post + +Posting target: `r/swift` + +Approval status: hold until `codepilotios` is at least 2 months old and has at least 5 posts/comments in `r/swift`, or moderators approve. + +Posting note: do not include a link in the first version unless mods approve. Frame as a Swift architecture question, not a launch. + +Title: + +```text +How would you model connection state for a SwiftUI client to a local Mac gateway? +``` + +Body: + +```text +I am working on CodePilot, an iPhone companion for a Mac app that runs a local token-protected gateway for Codex sessions. I am the maker, so this is related to my own project; no link unless the mods say that is appropriate. + +The SwiftUI side has to represent a few states cleanly: + +- no gateway URL/token configured +- local network URL configured but unreachable +- remote Cloudflare URL configured but gateway down +- token rejected +- gateway healthy but Codex account/session state unavailable +- active turn running +- active turn finished with logs/notifications available + +The current direction is to keep connection setup separate from session state, so the UI does not treat every failure as a generic network error. The app should show a specific recovery step: start gateway, copy current token, check tunnel, rotate token, or retry health check. + +For people building SwiftUI clients around local services: would you model this as one state machine, separate observable models, or typed error states flowing through async calls? I am trying to keep the UI honest without making onboarding feel like a debugger. +``` + +## Draft 6: `r/macapps` Monthly Megathread Comment + +Posting target: `r/macapps` monthly promotion megathread unless account qualifies for main feed. + +Approval status: needs maintainer approval before posting; hold until account has 10 local karma or mods approve. + +Rule notes: +- Use only official distribution links. +- No redirects, referrals, invite links, or shortened URLs. +- Disclose maker relationship. +- Do not use "Free" flair or pricing language for a beta with undecided commercial model. +- Current PCP format expects pricing and a link; because this run must not imply paid subscription details or post unapproved links, ask moderators before using a beta-feedback exception. + +Moderator note draft: + +```text +Hi mods, I am the maker of CodePilot, a beta Mac + iPhone companion for people running Codex from a Mac they control. + +I saw the PCP format for promotion and the pricing/link requirements. CodePilot is not ready for a pricing announcement, and I do not want to imply paid details or post an unapproved link. + +Would a no-link beta-feedback comment be acceptable in the monthly thread if it clearly says there is no pricing announcement, discloses that I am the maker, and asks only for setup/trust feedback from Mac users? If not, I will hold until there is an official distribution link and approved pricing language. +``` + +Megathread body if approved: + +```text +I am the maker of CodePilot, currently in beta. + +It is a Mac menu bar app plus iPhone companion for people running Codex from a Mac they control. The Mac app tracks local account/usage state and coordinates safe account switching. The local gateway lets the iPhone companion check status, browse threads, upload files, and steer or stop active turns without moving the provider login to the phone. + +Remote access is optional through a user-owned Cloudflare Tunnel, and the phone still needs the gateway token. The main thing I am looking for right now is setup and trust feedback from Mac users who already work with CLI coding agents. + +No pricing announcement here. I am trying to learn whether the beta setup is understandable and what security/setup explanation Mac users would expect before trying it. +``` + +## Draft 7: `r/OpenAI` Moderator-Approval Discussion + +Posting target: `r/OpenAI` + +Approval status: moderator approval required before posting. Do not include a link unless mods explicitly allow it. + +Moderator note draft: + +```text +Hi mods, I am the maker of CodePilot, a beta Mac + iPhone companion for people running Codex on their own Mac. I saw the rule/guidance against self-promotional direct project links, so I do not want to post a launch link. + +Would a text-only discussion asking Codex users what they would need before trusting an iPhone-to-local-Mac gateway setup be acceptable? I would disclose that I am the maker and avoid pricing or promotional claims. +``` + +Discussion body if approved: + +```text +I am the maker of CodePilot, a beta Mac + iPhone companion for people running Codex on their own Mac. I am not posting a link here; I am looking for product/security feedback from Codex users. + +The beta architecture is: Codex stays on the Mac, a local token-protected gateway exposes narrow session/status/file endpoints, and an iPhone app connects to that gateway for remote status, file upload, and turn controls. Optional remote access can use a user-owned Cloudflare Tunnel. + +For people who use Codex CLI seriously: what would you need to see before trusting a setup like this? + +- clear source/build instructions? +- documented token storage and rotation? +- gateway endpoint list? +- no remote access by default? +- security review checklist? +- something else? + +I am trying to make the beta setup transparent before inviting broader testers. +``` + +## Draft 8: `r/selfhosted` Moderator Note Only + +Posting target: `r/selfhosted` + +Approval status: hold. Moderator approval and maintainer approval required before any post or comment. Do not post until public docs are ready and the beta is production-ready enough for the community's self-promotion rule. + +Reason for hold: +- CodePilot includes a local gateway and optional user-owned tunnel, but it is not primarily a general self-hosted server product. +- Community rules expect promoted apps to be production-ready and documented. +- A beta feedback post could read as promotion unless moderators explicitly approve it. + +Moderator note draft: + +```text +Hi mods, I am the maker of CodePilot, a beta Mac + iPhone companion for people running Codex from a Mac they control. + +It includes a token-protected local gateway and optional user-owned Cloudflare Tunnel, but I am not sure it is a good fit for r/selfhosted because the product is mainly for Codex workflow control rather than general self-hosting. + +Would you prefer that I hold off until public docs and production readiness are stronger? If a text-only architecture/security feedback post would be acceptable later, I would disclose that I am the maker, avoid pricing language, and avoid posting a promotional link unless you approve it. +``` + +## Hacker News / Show HN Outline + +Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. + +Possible title: + +```text +Show HN: CodePilot - an iPhone companion for running Codex sessions from your Mac +``` + +Human rewrite outline: +- One sentence: CodePilot is a Mac menu bar app, local gateway, and iPhone companion for Codex users. +- Explain the personal problem: wanting to monitor/steer a long-running Codex session away from the desk without moving credentials to a hosted service. +- Explain the architecture: Codex remains on the Mac; iPhone talks to a token-protected local gateway; optional Cloudflare Tunnel for remote access. +- Be explicit that this is beta and feedback is requested on setup/security clarity. +- Do not ask for upvotes. +- Be present to answer technical questions. +- Only post when there is a public URL that lets people actually try it. + +## Product Hunt Draft Inputs + +Hold until a maintainer approves launch assets and current beta status. Do not fill pricing or promo fields without approval. + +Name: + +```text +CodePilot +``` + +Tagline draft: + +```text +iPhone companion for Codex sessions on your Mac +``` + +Description draft: + +```text +CodePilot connects a Mac menu bar app, local gateway, and iPhone companion so Codex users can check account/usage state, continue sessions, upload files, and steer or stop active turns from a Mac they control. Currently beta-focused; feedback wanted on setup, trust, and remote workflow clarity. +``` + +First comment outline: +- I am the maker. +- Built because coding-agent sessions live on the Mac, but users sometimes need status/control away from the desk. +- Explain the three pieces: Mac app, gateway, iPhone app. +- Explain security posture briefly: token-protected local gateway, optional user-owned tunnel, do not share tokens/logs. +- Ask for feedback, not upvotes. +- Avoid pricing and subscription language. + +## Feedback Tracking Template + +For each public reply after approved posting: + +```text +Source: +Link: +Type: setup | trust/security | feature | bug | positioning | pricing-question | other +Summary: +Action: +GitHub issue needed: yes/no +Escalation needed: yes/no +``` + +## Escalation Status + +No escalation file was created for this draft packet. Account creation, moderator messages, final post approval, and any public posting remain human approval steps, but none block draft preparation. From fb38ed3bffd99c305ba79e1d2e262e71b133d1c5 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Tue, 14 Jul 2026 06:56:24 +0200 Subject: [PATCH 20/57] docs: refresh CodePilot community drafts --- .../2026-07-01-codepilot-beta-drafts.md | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 1f854a6a..aba5836c 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -2,7 +2,7 @@ Status: draft only. Do not post publicly without explicit maintainer approval. -Last rule review: 2026-07-13. +Last rule review: 2026-07-14. Identity: `codepilotios` @@ -33,13 +33,13 @@ Operational rules for this queue: | Community | Fit | Rule summary | Recommendation | | --- | --- | --- | --- | -| `r/ChatGPTCoding` | High | Self-promotion thread permits project sharing, with rules including no selling model access and one promotion per project. Karma filters are active. Source: | Best first Reddit placement. Use the self-promotion thread only. Human approval before posting. | -| `r/codex` | High | Community is specifically for OpenAI Codex tools. Search-visible rules require posts to be directly related to Codex and avoid low-effort rants. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. Use text-only feedback framing unless mods approve a beta link. | -| `r/SideProject` | Medium-high | Community is explicitly for sharing and receiving feedback on side projects. Current posts show beta/product feedback asks are common. Source: | Good standalone beta feedback post. Keep it story/feedback-first, not a launch blast. | -| `r/iOSProgramming` | Medium | App posts are allowed only on Saturday, one app post per year, and should not come from accounts with no previous activity. App Saturday requires sections for tech stack, challenge/solution, and AI disclosure. Sources: and | Approval required. Hold until Saturday and until `codepilotios` has prior participation. | +| `r/ChatGPTCoding` | High | Current sidebar rules require civility, post flair, prompt disclosure where possible, no selling model access, and route service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The active self-promotion thread permits project sharing, but still says one promotion per project and no selling model access. Sources: and | Best first Reddit placement. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | +| `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Search-visible rules require posts to be directly related to Codex and avoid low-effort rants. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. Use text-only feedback framing unless mods approve a beta link. | +| `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | +| `r/iOSProgramming` | Medium | App posts are allowed only on Saturday, one app post per year, and should not come from accounts with no previous activity. Current rule text says App Saturday posts must follow the wiki content guideline; moderator reminders reinforce Saturday timing and one-app-per-year. A proposed stricter participation requirement has been discussed but is not treated here as active unless adopted in the sidebar/wiki. Sources: and | Approval required. Hold until Saturday and until `codepilotios` has prior participation; re-check the wiki immediately before use. | | `r/swift` | Medium | Self-promotion is tolerated only for active contributors; fewer than 5 posts/comments in the sub or account age under 2 months means no self-promotion. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after account age/activity threshold is met. | -| `r/macapps` | Medium | Main feed promotion requires qualification, 10 local karma, official distribution links only, no redirects/referrals, 30-day promotion spacing, relationship disclosure, and monthly megathread fallback. Recent megathreads require PCP format, including pricing amounts and a link. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | -| `r/OpenAI` | Medium-low | Search-visible rule text says self-promotional direct link posts to projects are not allowed and points to self-promo guidance. Source: | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | +| `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and a pinned June 2026 App Pile megathread. Existing rule notes still require qualification, 10 local karma, official distribution links only, no redirects/referrals, 30-day promotion spacing, relationship disclosure, and monthly megathread fallback. Recent megathreads require PCP format, including pricing amounts and a link. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | +| `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | | `r/MacOS` | Low for now | Mod post says self-promotion is allowed only for apps available in the macOS App Store; otherwise use `r/macapps`. Source: | Do not post unless/until CodePilot Mac is in the macOS App Store or mods explicitly approve. | | `r/selfhosted` | Low-medium later | Self-promotion is limited by Reddit self-promotion norms; promoted apps must be production-ready and have docs, and comments should add relevant value. Source: | Hold for now. CodePilot has a local gateway and optional user-owned tunnel, but this is not primarily a self-hosted server product and should not be promoted there until public docs and production readiness are stronger. | | `r/ClaudeAI` | Low | Search-visible rules and moderation comments indicate self-promotion, solicitation, and AI-created promotional content are not allowed. Source: | Do not post CodePilot promotion. Only answer organically if directly relevant and approved by a maintainer. | @@ -49,8 +49,9 @@ Operational rules for this queue: ## Rules That Require Approval Before Posting - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. +- `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply. -- `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, mandatory App Saturday content sections. +- `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, mandatory App Saturday content sections; re-check the App Saturday wiki before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit. - `r/macapps`: requires 10 local karma and qualification for main feed; otherwise use monthly megathread. Official distribution links only. Current megathread PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. @@ -92,6 +93,7 @@ Approval status: needs maintainer approval before posting. Rule notes: - Fits the self-promotion thread. +- Use a comment in the thread, not a main-feed post, unless moderators explicitly approve otherwise. - Do not mention selling access to models. - Promote once per project. - Expect karma filtering if `codepilotios` is new. @@ -111,6 +113,14 @@ I am looking for a small number of beta testers who already use Codex CLI on mac This is not a paid-subscription announcement and I am not asking for upvotes. If this matches your workflow, reply here with what setup you use for Codex today, and I will share the approved beta link once it is ready for public distribution. ``` +Moderator note if main-feed placement is desired later: + +```text +Hi mods, I am the maker of CodePilot, a beta Mac + iPhone companion for people running Codex from a Mac they control. + +I saw the self-promotion rule and the designated promotion thread. For now I plan to use only that thread. If there is a future version that would be useful as a main-feed technical discussion, should I request sponsorship/mod approval first, or should all CodePilot mentions stay in the promotion thread? +``` + ## Draft 2: `r/codex` Moderator-First Beta Feedback Post Posting target: `r/codex` @@ -158,6 +168,11 @@ Posting target: `r/SideProject` Approval status: needs maintainer approval before posting. +Rule notes: +- Fit is strongest when framed as a specific side-project feedback request. +- Do not ask friends, testers, or communities to upvote. +- Include only an approved public link if links are allowed at posting time; otherwise ask for replies and share the approved beta link only after maintainer approval. + Title options: ```text @@ -188,6 +203,16 @@ I am not announcing pricing or asking for votes. I am trying to learn whether th If you use Codex CLI on macOS: what would make you trust or reject a setup like this? What would you want to see before installing a beta that touches local coding-agent credentials? ``` +No-link variant if link placement is unclear: + +```text +I am the maker of CodePilot, a beta Mac + iPhone companion for people who run Codex on a Mac. + +I am not dropping a launch link here. I am trying to pressure-test the idea before wider distribution: Codex stays on the Mac, a token-protected local gateway exposes narrow session/status/file endpoints, and an iPhone app connects to that gateway for status, file upload, and turn controls. Remote access can use a user-owned Cloudflare Tunnel, but the provider login stays on the Mac. + +For people who build or use developer tools: would this solve a real workflow problem, or would the local gateway/auth-file aspect make you reject it? What would you need documented before trying a beta? +``` + ## Draft 4: `r/iOSProgramming` App Saturday Post Posting target: `r/iOSProgramming`, Saturday only. From 380b78b77a83e4c8e156b9720534d79f23cdd5b6 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Tue, 14 Jul 2026 07:52:56 +0200 Subject: [PATCH 21/57] docs: update community promotion rules --- .../2026-07-01-codepilot-beta-drafts.md | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index aba5836c..a1dde59f 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -4,6 +4,10 @@ Status: draft only. Do not post publicly without explicit maintainer approval. Last rule review: 2026-07-14. +Review method: checked public Reddit rule/sidebar/search-visible pages and recent +promotion threads on 2026-07-14. Re-check each target immediately before any +human-approved posting because subreddit rules and megathread formats change. + Identity: `codepilotios` Scope: @@ -38,9 +42,9 @@ Operational rules for this queue: | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | | `r/iOSProgramming` | Medium | App posts are allowed only on Saturday, one app post per year, and should not come from accounts with no previous activity. Current rule text says App Saturday posts must follow the wiki content guideline; moderator reminders reinforce Saturday timing and one-app-per-year. A proposed stricter participation requirement has been discussed but is not treated here as active unless adopted in the sidebar/wiki. Sources: and | Approval required. Hold until Saturday and until `codepilotios` has prior participation; re-check the wiki immediately before use. | | `r/swift` | Medium | Self-promotion is tolerated only for active contributors; fewer than 5 posts/comments in the sub or account age under 2 months means no self-promotion. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after account age/activity threshold is met. | -| `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and a pinned June 2026 App Pile megathread. Existing rule notes still require qualification, 10 local karma, official distribution links only, no redirects/referrals, 30-day promotion spacing, relationship disclosure, and monthly megathread fallback. Recent megathreads require PCP format, including pricing amounts and a link. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | +| `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and the July 2026 App Pile megathread. Rule/sidebar snippets require 10 local karma, megathread promotion unless the account qualifies through trust/transparency, post approval, and PCP-style promotion. Recent megathreads require pricing and link fields. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | -| `r/MacOS` | Low for now | Mod post says self-promotion is allowed only for apps available in the macOS App Store; otherwise use `r/macapps`. Source: | Do not post unless/until CodePilot Mac is in the macOS App Store or mods explicitly approve. | +| `r/MacOS` | Low for now | Current rule/sidebar snippets say promotional posts are Saturday-only UTC, no bots, and limited to apps available in the official Mac App Store or reputable established GitHub repositories. Earlier mod guidance pointed non-App-Store apps to `r/macapps`. Sources: and | Do not post unless/until moderators confirm CodePilot's public GitHub repo qualifies as reputable/established, or the Mac app is in the macOS App Store. | | `r/selfhosted` | Low-medium later | Self-promotion is limited by Reddit self-promotion norms; promoted apps must be production-ready and have docs, and comments should add relevant value. Source: | Hold for now. CodePilot has a local gateway and optional user-owned tunnel, but this is not primarily a self-hosted server product and should not be promoted there until public docs and production readiness are stronger. | | `r/ClaudeAI` | Low | Search-visible rules and moderation comments indicate self-promotion, solicitation, and AI-created promotional content are not allowed. Source: | Do not post CodePilot promotion. Only answer organically if directly relevant and approved by a maintainer. | | Hacker News / Show HN | Medium | Show HN is for something people can try; HN asks submitters not to use it primarily for promotion and not to post generated or AI-edited comments. Sources: and | Human rewrite required. Prepare outline only; do not paste AI-generated text. | @@ -53,9 +57,9 @@ Operational rules for this queue: - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, mandatory App Saturday content sections; re-check the App Saturday wiki before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit. -- `r/macapps`: requires 10 local karma and qualification for main feed; otherwise use monthly megathread. Official distribution links only. Current megathread PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. +- `r/macapps`: requires 10 local karma, trust/transparency qualification or monthly megathread use, and post approval. Current App Pile PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. -- `r/MacOS`: hold unless CodePilot Mac is in the macOS App Store or mods approve. +- `r/MacOS`: Saturday-only UTC, no bots, and limited to Mac App Store apps or reputable established GitHub repositories. Hold unless moderators confirm CodePilot qualifies or the Mac app is in the macOS App Store. - `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; ask mods before any maker-authored mention. - `r/ClaudeAI`: avoid promotional posting because rules disallow self-promotion/solicitation and AI-created promotional content. - Hacker News: a maintainer should write the actual comment manually because HN asks users not to post generated or AI-edited comments. @@ -296,7 +300,7 @@ Rule notes: - No redirects, referrals, invite links, or shortened URLs. - Disclose maker relationship. - Do not use "Free" flair or pricing language for a beta with undecided commercial model. -- Current PCP format expects pricing and a link; because this run must not imply paid subscription details or post unapproved links, ask moderators before using a beta-feedback exception. +- Current App Pile PCP format expects pricing and a link; because this run must not imply paid subscription details or post unapproved links, ask moderators before using a beta-feedback exception. Moderator note draft: @@ -374,6 +378,30 @@ It includes a token-protected local gateway and optional user-owned Cloudflare T Would you prefer that I hold off until public docs and production readiness are stronger? If a text-only architecture/security feedback post would be acceptable later, I would disclose that I am the maker, avoid pricing language, and avoid posting a promotional link unless you approve it. ``` +## Draft 9: `r/MacOS` Moderator Note Only + +Posting target: `r/MacOS` + +Approval status: hold. Moderator approval and maintainer approval required +before any post or comment. Do not post from an automated account. + +Reason for hold: +- Current rules allow Saturday UTC promotion for Mac App Store apps or reputable + established GitHub repositories. +- CodePilot is source-available and public, but whether the repository is + "reputable" and "established" is a moderator judgment. +- The launch plan forbids public posting without maintainer approval. + +Moderator note draft: + +```text +Hi mods, I am the maker of CodePilot, a beta Mac menu bar app plus iPhone companion for people running Codex from a Mac they control. + +I saw the Saturday-only promotion rule and the requirement that non-App-Store apps use reputable, established GitHub repositories. CodePilot is source-available and public, but I do not want to assume it qualifies. + +Would a Saturday text-only beta-feedback post be acceptable if it discloses that I am the maker, avoids pricing language, avoids asking for votes, and focuses on setup/security feedback from Mac users? If not, I will hold off or use r/macapps if that is the better fit. +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. From e004c8cc1e9fbdc1b72979e36496acc75d1bdb72 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 03:02:46 +0200 Subject: [PATCH 22/57] docs: refresh community beta drafts --- .../2026-07-01-codepilot-beta-drafts.md | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index a1dde59f..bcfdf953 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -2,10 +2,11 @@ Status: draft only. Do not post publicly without explicit maintainer approval. -Last rule review: 2026-07-14. +Last rule review: 2026-07-17. -Review method: checked public Reddit rule/sidebar/search-visible pages and recent -promotion threads on 2026-07-14. Re-check each target immediately before any +Review method: checked public Reddit rule/sidebar/search-visible pages, moderator +announcements, removal notices, and recent promotion threads on 2026-07-17. +Re-check each target immediately before any human-approved posting because subreddit rules and megathread formats change. Identity: `codepilotios` @@ -37,15 +38,15 @@ Operational rules for this queue: | Community | Fit | Rule summary | Recommendation | | --- | --- | --- | --- | -| `r/ChatGPTCoding` | High | Current sidebar rules require civility, post flair, prompt disclosure where possible, no selling model access, and route service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The active self-promotion thread permits project sharing, but still says one promotion per project and no selling model access. Sources: and | Best first Reddit placement. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | +| `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Search-visible rules require posts to be directly related to Codex and avoid low-effort rants. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. Use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | -| `r/iOSProgramming` | Medium | App posts are allowed only on Saturday, one app post per year, and should not come from accounts with no previous activity. Current rule text says App Saturday posts must follow the wiki content guideline; moderator reminders reinforce Saturday timing and one-app-per-year. A proposed stricter participation requirement has been discussed but is not treated here as active unless adopted in the sidebar/wiki. Sources: and | Approval required. Hold until Saturday and until `codepilotios` has prior participation; re-check the wiki immediately before use. | -| `r/swift` | Medium | Self-promotion is tolerated only for active contributors; fewer than 5 posts/comments in the sub or account age under 2 months means no self-promotion. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after account age/activity threshold is met. | +| `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. Current enforcement requires the sections `Tech Stack`, `Development Challenge`, and `AI Disclosure`; the community also proposed a 20-karma-in-six-months participation gate, which must be confirmed against the live rules before posting. Sources: , , and | Approval required. Hold until Saturday, confirm the live karma gate, and do not use a new/inactive `codepilotios` account. | +| `r/swift` | Medium | Self-promotion is limited to once per month and at most 20% of the account's posts to the subreddit; it is not allowed with fewer than 5 posts/comments in the sub or when the account is under 2 months old. Sources: and | Approval required. Do not post from a new `codepilotios` account. Use only after account age/activity thresholds are met. | | `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and the July 2026 App Pile megathread. Rule/sidebar snippets require 10 local karma, megathread promotion unless the account qualifies through trust/transparency, post approval, and PCP-style promotion. Recent megathreads require pricing and link fields. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | | `r/MacOS` | Low for now | Current rule/sidebar snippets say promotional posts are Saturday-only UTC, no bots, and limited to apps available in the official Mac App Store or reputable established GitHub repositories. Earlier mod guidance pointed non-App-Store apps to `r/macapps`. Sources: and | Do not post unless/until moderators confirm CodePilot's public GitHub repo qualifies as reputable/established, or the Mac app is in the macOS App Store. | -| `r/selfhosted` | Low-medium later | Self-promotion is limited by Reddit self-promotion norms; promoted apps must be production-ready and have docs, and comments should add relevant value. Source: | Hold for now. CodePilot has a local gateway and optional user-owned tunnel, but this is not primarily a self-hosted server product and should not be promoted there until public docs and production readiness are stronger. | +| `r/selfhosted` | Low-medium later | Mobile apps are allowed only as companions to a self-hosted backend. Promoted apps must be self-hostable, released and tryable, production-ready, documented, and described with features and user benefit. Projects under 3 months old may be posted only on New Project Friday. Sources: and | Hold for now. CodePilot has a local gateway and user-owned tunnel, but this is not primarily a general self-hosted server product; ask moderators only after public docs and production readiness are stronger. | | `r/ClaudeAI` | Low | Search-visible rules and moderation comments indicate self-promotion, solicitation, and AI-created promotional content are not allowed. Source: | Do not post CodePilot promotion. Only answer organically if directly relevant and approved by a maintainer. | | Hacker News / Show HN | Medium | Show HN is for something people can try; HN asks submitters not to use it primarily for promotion and not to post generated or AI-edited comments. Sources: and | Human rewrite required. Prepare outline only; do not paste AI-generated text. | | Product Hunt | Medium later | Product Hunt recommends clear product pages, maker first comment, feedback requests rather than upvote requests, and warns against spamming communities where you have not been active. Sources: and | Not a first beta move. Requires account, launch assets, and pricing/status decisions, so hold for human approval. | @@ -55,12 +56,12 @@ Operational rules for this queue: - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. - `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply. -- `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, mandatory App Saturday content sections; re-check the App Saturday wiki before posting. -- `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit. +- `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. Confirm whether the proposed 20 local karma in 6 months gate is present in the live rules before posting. +- `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; also respect the once-per-month and 20%-of-posts limits. - `r/macapps`: requires 10 local karma, trust/transparency qualification or monthly megathread use, and post approval. Current App Pile PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. - `r/MacOS`: Saturday-only UTC, no bots, and limited to Mac App Store apps or reputable established GitHub repositories. Hold unless moderators confirm CodePilot qualifies or the Mac app is in the macOS App Store. -- `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; ask mods before any maker-authored mention. +- `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; projects under 3 months old are Friday-only, and any maker-authored mention still needs moderator and maintainer approval. - `r/ClaudeAI`: avoid promotional posting because rules disallow self-promotion/solicitation and AI-created promotional content. - Hacker News: a maintainer should write the actual comment manually because HN asks users not to post generated or AI-edited comments. - Product Hunt: hold until account, maker identity, screenshots, launch URL, and current beta/pricing status are approved. @@ -105,7 +106,7 @@ Rule notes: ```text Hi, I am the maker of CodePilot. -It is a beta Mac + iPhone companion for people already running Codex on their own Mac. The Mac side watches local Codex account/usage state and runs a token-protected local gateway. The iPhone side connects to that gateway so you can check active account state, continue a session away from the desk, upload files, steer/stop an active turn, and optionally use a Cloudflare Tunnel for remote access. +It is a beta Mac + iPhone companion for people already running Codex on their own Mac. The Mac side watches local Codex account/usage state and runs a token-protected local gateway. The iPhone side connects through a user-owned Cloudflare Tunnel to that gateway so you can check active account state, continue a session away from the desk, upload files, and steer or stop an active turn. I am looking for a small number of beta testers who already use Codex CLI on macOS and are willing to give blunt setup feedback. The areas I most want tested are: @@ -114,7 +115,7 @@ I am looking for a small number of beta testers who already use Codex CLI on mac - whether remote session control from iPhone actually solves a real workflow problem - what errors or recovery steps are still confusing -This is not a paid-subscription announcement and I am not asking for upvotes. If this matches your workflow, reply here with what setup you use for Codex today, and I will share the approved beta link once it is ready for public distribution. +This is a beta feedback request, and I am not asking for upvotes. If this matches your workflow, reply here with what setup you use for Codex today, and I will share the approved beta link once it is ready for public distribution. ``` Moderator note if main-feed placement is desired later: @@ -154,7 +155,7 @@ I am the maker of CodePilot, and I am looking for feedback from people who use C The beta is built around a simple constraint: Codex stays on the Mac where the CLI session, auth files, account state, and project files already live. CodePilot adds a Mac menu bar app, a token-protected local gateway, and an iPhone client for status, files, notifications, and turn controls. -Optional remote access can use a user-owned Cloudflare Tunnel, but the phone still has to authenticate to the Mac gateway. I am trying to keep the trust boundary explicit instead of turning this into a hosted Codex proxy. +For the current public-beta iPhone path, a user-owned Cloudflare Tunnel reaches the loopback-only gateway, and the phone still has to authenticate to it. I am trying to keep the trust boundary explicit instead of turning this into a hosted Codex proxy. The feedback I am looking for: @@ -163,7 +164,7 @@ The feedback I am looking for: - Which setup step would make you stop: gateway token, tunnel setup, iOS pairing, file upload, or remote turn control? - What documentation would you expect before trying a beta? -No pricing announcement and no upvote request. I am trying to make the beta safer and clearer before broader distribution. +This is a beta feedback request, not an upvote request. I am trying to make the beta safer and clearer before broader distribution. ``` ## Draft 3: `r/SideProject` Standalone Feedback Post @@ -200,9 +201,9 @@ So I built a beta with three pieces: - a local token-protected Python gateway on the Mac - an iPhone app that connects to the gateway for threads, files, status, and turn controls -Remote access is optional through a user-owned Cloudflare Tunnel. The iOS app talks to the Mac gateway, not directly to provider services. +The current public-beta iPhone setup uses a user-owned Cloudflare Tunnel because the Mac gateway listens on loopback by default. The iOS app talks to that gateway, not directly to provider services. -I am not announcing pricing or asking for votes. I am trying to learn whether the setup is understandable and whether this solves a real problem for Codex users. +I am asking for setup feedback, not votes. I am trying to learn whether the setup is understandable and whether this solves a real problem for Codex users. If you use Codex CLI on macOS: what would make you trust or reject a setup like this? What would you want to see before installing a beta that touches local coding-agent credentials? ``` @@ -212,7 +213,7 @@ No-link variant if link placement is unclear: ```text I am the maker of CodePilot, a beta Mac + iPhone companion for people who run Codex on a Mac. -I am not dropping a launch link here. I am trying to pressure-test the idea before wider distribution: Codex stays on the Mac, a token-protected local gateway exposes narrow session/status/file endpoints, and an iPhone app connects to that gateway for status, file upload, and turn controls. Remote access can use a user-owned Cloudflare Tunnel, but the provider login stays on the Mac. +I am not dropping a launch link here. I am trying to pressure-test the idea before wider distribution: Codex stays on the Mac, a token-protected loopback gateway exposes narrow session/status/file endpoints, and the current public-beta iPhone path reaches that gateway through a user-owned Cloudflare Tunnel for status, file upload, and turn controls. The provider login stays on the Mac. For people who build or use developer tools: would this solve a real workflow problem, or would the local gateway/auth-file aspect make you reject it? What would you need documented before trying a beta? ``` @@ -223,6 +224,10 @@ Posting target: `r/iOSProgramming`, Saturday only. Approval status: needs maintainer approval before posting; do not post from a new or inactive account. +Posting gate: Saturday only, one post per app per year, and all three required +sections below. Confirm the live participation threshold before approval; the +announced threshold was 20 `r/iOSProgramming` karma earned in the prior 6 months. + Title: ```text @@ -236,7 +241,7 @@ I am the maker of CodePilot, a TestFlight beta for people who run Codex on a Mac Tech Stack Used -The iOS app is built with SwiftUI. It talks to a token-protected local Python gateway running on the user's Mac. The Mac app is a menu bar utility that watches local Codex account and usage state. Optional remote access uses a user-owned Cloudflare Tunnel, but the iOS app still authenticates to the Mac gateway with a bearer token. +The iOS app is built with SwiftUI. It talks to a token-protected local Python gateway running on the user's Mac. The Mac app is a menu bar utility that watches local Codex account and usage state. For the current public-beta path, the iOS app reaches the loopback-only gateway through a user-owned Cloudflare Tunnel and still authenticates with a bearer token. The iPhone client currently focuses on connection setup, gateway health, thread/session status, file uploads, usage/account status, notifications, and controls for steering or stopping active turns. @@ -259,7 +264,7 @@ I am looking for feedback from iOS/macOS developers who use coding agents: does Posting target: `r/swift` -Approval status: hold until `codepilotios` is at least 2 months old and has at least 5 posts/comments in `r/swift`, or moderators approve. +Approval status: hold until `codepilotios` is at least 2 months old and has at least 5 posts/comments in `r/swift`, or moderators approve. If eligible, self-promotion is still limited to once per month and no more than 20% of the account's posts to the subreddit. Posting note: do not include a link in the first version unless mods approve. Frame as a Swift architecture question, not a launch. @@ -319,9 +324,9 @@ I am the maker of CodePilot, currently in beta. It is a Mac menu bar app plus iPhone companion for people running Codex from a Mac they control. The Mac app tracks local account/usage state and coordinates safe account switching. The local gateway lets the iPhone companion check status, browse threads, upload files, and steer or stop active turns without moving the provider login to the phone. -Remote access is optional through a user-owned Cloudflare Tunnel, and the phone still needs the gateway token. The main thing I am looking for right now is setup and trust feedback from Mac users who already work with CLI coding agents. +The current public-beta iPhone path uses a user-owned Cloudflare Tunnel because the gateway listens on loopback, and the phone still needs the gateway token. The main thing I am looking for right now is setup and trust feedback from Mac users who already work with CLI coding agents. -No pricing announcement here. I am trying to learn whether the beta setup is understandable and what security/setup explanation Mac users would expect before trying it. +I am trying to learn whether the beta setup is understandable and what security/setup explanation Mac users would expect before trying it. ``` ## Draft 7: `r/OpenAI` Moderator-Approval Discussion @@ -343,7 +348,7 @@ Discussion body if approved: ```text I am the maker of CodePilot, a beta Mac + iPhone companion for people running Codex on their own Mac. I am not posting a link here; I am looking for product/security feedback from Codex users. -The beta architecture is: Codex stays on the Mac, a local token-protected gateway exposes narrow session/status/file endpoints, and an iPhone app connects to that gateway for remote status, file upload, and turn controls. Optional remote access can use a user-owned Cloudflare Tunnel. +The beta architecture is: Codex stays on the Mac, a local token-protected gateway exposes narrow session/status/file endpoints, and the current public-beta iPhone path connects through a user-owned Cloudflare Tunnel for remote status, file upload, and turn controls. For people who use Codex CLI seriously: what would you need to see before trusting a setup like this? @@ -364,8 +369,9 @@ Posting target: `r/selfhosted` Approval status: hold. Moderator approval and maintainer approval required before any post or comment. Do not post until public docs are ready and the beta is production-ready enough for the community's self-promotion rule. Reason for hold: -- CodePilot includes a local gateway and optional user-owned tunnel, but it is not primarily a general self-hosted server product. +- CodePilot includes a local gateway and a user-owned tunnel for the current public-beta iPhone path, but it is not primarily a general self-hosted server product. - Community rules expect promoted apps to be production-ready and documented. +- A project under 3 months old may be introduced only on New Project Friday; confirm project age and the live Friday rule before any later submission. - A beta feedback post could read as promotion unless moderators explicitly approve it. Moderator note draft: @@ -373,7 +379,7 @@ Moderator note draft: ```text Hi mods, I am the maker of CodePilot, a beta Mac + iPhone companion for people running Codex from a Mac they control. -It includes a token-protected local gateway and optional user-owned Cloudflare Tunnel, but I am not sure it is a good fit for r/selfhosted because the product is mainly for Codex workflow control rather than general self-hosting. +It includes a token-protected local gateway and uses a user-owned Cloudflare Tunnel for the current public-beta iPhone path, but I am not sure it is a good fit for r/selfhosted because the product is mainly for Codex workflow control rather than general self-hosting. Would you prefer that I hold off until public docs and production readiness are stronger? If a text-only architecture/security feedback post would be acceptable later, I would disclose that I am the maker, avoid pricing language, and avoid posting a promotional link unless you approve it. ``` @@ -415,7 +421,7 @@ Show HN: CodePilot - an iPhone companion for running Codex sessions from your Ma Human rewrite outline: - One sentence: CodePilot is a Mac menu bar app, local gateway, and iPhone companion for Codex users. - Explain the personal problem: wanting to monitor/steer a long-running Codex session away from the desk without moving credentials to a hosted service. -- Explain the architecture: Codex remains on the Mac; iPhone talks to a token-protected local gateway; optional Cloudflare Tunnel for remote access. +- Explain the architecture: Codex remains on the Mac; the current public-beta iPhone path reaches the token-protected, loopback-only gateway through a user-owned Cloudflare Tunnel. - Be explicit that this is beta and feedback is requested on setup/security clarity. - Do not ask for upvotes. - Be present to answer technical questions. @@ -447,7 +453,7 @@ First comment outline: - I am the maker. - Built because coding-agent sessions live on the Mac, but users sometimes need status/control away from the desk. - Explain the three pieces: Mac app, gateway, iPhone app. -- Explain security posture briefly: token-protected local gateway, optional user-owned tunnel, do not share tokens/logs. +- Explain security posture briefly: token-protected loopback gateway, user-owned tunnel for the current public-beta iPhone path, and no sharing of tokens or private logs. - Ask for feedback, not upvotes. - Avoid pricing and subscription language. From 1c778a3140b2abb44ed9584efc8acec3b759c716 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 04:00:32 +0200 Subject: [PATCH 23/57] docs: correct current community promotion rules --- .../2026-07-01-codepilot-beta-drafts.md | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index bcfdf953..df089e9f 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -41,13 +41,13 @@ Operational rules for this queue: | `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Search-visible rules require posts to be directly related to Codex and avoid low-effort rants. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. Use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | -| `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. Current enforcement requires the sections `Tech Stack`, `Development Challenge`, and `AI Disclosure`; the community also proposed a 20-karma-in-six-months participation gate, which must be confirmed against the live rules before posting. Sources: , , and | Approval required. Hold until Saturday, confirm the live karma gate, and do not use a new/inactive `codepilotios` account. | -| `r/swift` | Medium | Self-promotion is limited to once per month and at most 20% of the account's posts to the subreddit; it is not allowed with fewer than 5 posts/comments in the sub or when the account is under 2 months old. Sources: and | Approval required. Do not post from a new `codepilotios` account. Use only after account age/activity thresholds are met. | +| `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | +| `r/swift` | Medium | Self-promotion must be under 5% of the account's posts and comments in the subreddit. It is not allowed with fewer than 5 posts/comments in the sub or when the account is under 2 months old. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after the account age, activity, and contribution-ratio thresholds are met. | | `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and the July 2026 App Pile megathread. Rule/sidebar snippets require 10 local karma, megathread promotion unless the account qualifies through trust/transparency, post approval, and PCP-style promotion. Recent megathreads require pricing and link fields. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | | `r/MacOS` | Low for now | Current rule/sidebar snippets say promotional posts are Saturday-only UTC, no bots, and limited to apps available in the official Mac App Store or reputable established GitHub repositories. Earlier mod guidance pointed non-App-Store apps to `r/macapps`. Sources: and | Do not post unless/until moderators confirm CodePilot's public GitHub repo qualifies as reputable/established, or the Mac app is in the macOS App Store. | -| `r/selfhosted` | Low-medium later | Mobile apps are allowed only as companions to a self-hosted backend. Promoted apps must be self-hostable, released and tryable, production-ready, documented, and described with features and user benefit. Projects under 3 months old may be posted only on New Project Friday. Sources: and | Hold for now. CodePilot has a local gateway and user-owned tunnel, but this is not primarily a general self-hosted server product; ask moderators only after public docs and production readiness are stronger. | -| `r/ClaudeAI` | Low | Search-visible rules and moderation comments indicate self-promotion, solicitation, and AI-created promotional content are not allowed. Source: | Do not post CodePilot promotion. Only answer organically if directly relevant and approved by a maintainer. | +| `r/selfhosted` | Low-medium later | Mobile apps are allowed only as companions to a self-hosted backend. Promoted apps must be production-ready and documented. Projects under 3 months old may be posted only in the current New Project Megathread; Wednesday has a separate exception for dashboards and tools that help self-hosters. Source: | Hold for now. CodePilot has a local gateway and user-owned tunnel, but this is not primarily a general self-hosted server product; ask moderators only after public docs and production readiness are stronger. | +| `r/ClaudeAI` | Low | Project showcases must be built with Claude/Claude Code or specifically for Claude by the poster, be free to try, explain how Claude helped, use minimal promotional language, and come from an account with more than 50 karma. Source: | Do not post CodePilot promotion because this Codex-focused beta does not meet the stated Claude-project relevance gate. Only answer organically if directly relevant and approved by a maintainer. | | Hacker News / Show HN | Medium | Show HN is for something people can try; HN asks submitters not to use it primarily for promotion and not to post generated or AI-edited comments. Sources: and | Human rewrite required. Prepare outline only; do not paste AI-generated text. | | Product Hunt | Medium later | Product Hunt recommends clear product pages, maker first comment, feedback requests rather than upvote requests, and warns against spamming communities where you have not been active. Sources: and | Not a first beta move. Requires account, launch assets, and pricing/status decisions, so hold for human approval. | @@ -56,13 +56,13 @@ Operational rules for this queue: - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. - `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply. -- `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. Confirm whether the proposed 20 local karma in 6 months gate is present in the live rules before posting. -- `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; also respect the once-per-month and 20%-of-posts limits. +- `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. +- `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. - `r/macapps`: requires 10 local karma, trust/transparency qualification or monthly megathread use, and post approval. Current App Pile PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. - `r/MacOS`: Saturday-only UTC, no bots, and limited to Mac App Store apps or reputable established GitHub repositories. Hold unless moderators confirm CodePilot qualifies or the Mac app is in the macOS App Store. -- `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; projects under 3 months old are Friday-only, and any maker-authored mention still needs moderator and maintainer approval. -- `r/ClaudeAI`: avoid promotional posting because rules disallow self-promotion/solicitation and AI-created promotional content. +- `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; projects under 3 months old belong only in the current New Project Megathread, and any maker-authored mention still needs moderator and maintainer approval. +- `r/ClaudeAI`: do not promote this Codex-focused beta because it does not meet the community's Claude-built/Claude-focused showcase gate; eligible showcases also require more than 50 account karma. - Hacker News: a maintainer should write the actual comment manually because HN asks users not to post generated or AI-edited comments. - Product Hunt: hold until account, maker identity, screenshots, launch URL, and current beta/pricing status are approved. @@ -224,9 +224,8 @@ Posting target: `r/iOSProgramming`, Saturday only. Approval status: needs maintainer approval before posting; do not post from a new or inactive account. -Posting gate: Saturday only, one post per app per year, and all three required -sections below. Confirm the live participation threshold before approval; the -announced threshold was 20 `r/iOSProgramming` karma earned in the prior 6 months. +Posting gate: Saturday only, one post per app per year, prior community activity, +and all three required sections below. Re-check the live rules before approval. Title: @@ -264,7 +263,7 @@ I am looking for feedback from iOS/macOS developers who use coding agents: does Posting target: `r/swift` -Approval status: hold until `codepilotios` is at least 2 months old and has at least 5 posts/comments in `r/swift`, or moderators approve. If eligible, self-promotion is still limited to once per month and no more than 20% of the account's posts to the subreddit. +Approval status: hold until `codepilotios` is at least 2 months old and has at least 5 posts/comments in `r/swift`, or moderators approve. If eligible, self-promotion must remain below 5% of the account's posts and comments in the subreddit. Posting note: do not include a link in the first version unless mods approve. Frame as a Swift architecture question, not a launch. @@ -371,7 +370,7 @@ Approval status: hold. Moderator approval and maintainer approval required befor Reason for hold: - CodePilot includes a local gateway and a user-owned tunnel for the current public-beta iPhone path, but it is not primarily a general self-hosted server product. - Community rules expect promoted apps to be production-ready and documented. -- A project under 3 months old may be introduced only on New Project Friday; confirm project age and the live Friday rule before any later submission. +- A project under 3 months old may be introduced only in the current New Project Megathread; confirm project age and the live megathread before any later submission. - A beta feedback post could read as promotion unless moderators explicitly approve it. Moderator note draft: From d8ef5d9fd8e6100664fe4dc0eb0f1fedece9dd9a Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 05:33:00 +0200 Subject: [PATCH 24/57] docs: refresh macOS community draft policy --- .../2026-07-01-codepilot-beta-drafts.md | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index df089e9f..ead37de2 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -45,7 +45,7 @@ Operational rules for this queue: | `r/swift` | Medium | Self-promotion must be under 5% of the account's posts and comments in the subreddit. It is not allowed with fewer than 5 posts/comments in the sub or when the account is under 2 months old. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after the account age, activity, and contribution-ratio thresholds are met. | | `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and the July 2026 App Pile megathread. Rule/sidebar snippets require 10 local karma, megathread promotion unless the account qualifies through trust/transparency, post approval, and PCP-style promotion. Recent megathreads require pricing and link fields. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | -| `r/MacOS` | Low for now | Current rule/sidebar snippets say promotional posts are Saturday-only UTC, no bots, and limited to apps available in the official Mac App Store or reputable established GitHub repositories. Earlier mod guidance pointed non-App-Store apps to `r/macapps`. Sources: and | Do not post unless/until moderators confirm CodePilot's public GitHub repo qualifies as reputable/established, or the Mac app is in the macOS App Store. | +| `r/MacOS` | Medium | The March 2026 Developer Saturday policy permits one self-promotional post per user each Saturday from 00:00 through 23:59 UTC. It explicitly welcomes accessible GitHub repositories, which are scanned by GitHub-Guard, and requires affiliation disclosure, useful context, and more than a low-effort link drop. Source: | Candidate Saturday placement after maintainer approval. Use a transparent, context-rich beta-feedback post and link only to an approved, accessible, security-ready repository or distribution page. | | `r/selfhosted` | Low-medium later | Mobile apps are allowed only as companions to a self-hosted backend. Promoted apps must be production-ready and documented. Projects under 3 months old may be posted only in the current New Project Megathread; Wednesday has a separate exception for dashboards and tools that help self-hosters. Source: | Hold for now. CodePilot has a local gateway and user-owned tunnel, but this is not primarily a general self-hosted server product; ask moderators only after public docs and production readiness are stronger. | | `r/ClaudeAI` | Low | Project showcases must be built with Claude/Claude Code or specifically for Claude by the poster, be free to try, explain how Claude helped, use minimal promotional language, and come from an account with more than 50 karma. Source: | Do not post CodePilot promotion because this Codex-focused beta does not meet the stated Claude-project relevance gate. Only answer organically if directly relevant and approved by a maintainer. | | Hacker News / Show HN | Medium | Show HN is for something people can try; HN asks submitters not to use it primarily for promotion and not to post generated or AI-edited comments. Sources: and | Human rewrite required. Prepare outline only; do not paste AI-generated text. | @@ -60,7 +60,7 @@ Operational rules for this queue: - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. - `r/macapps`: requires 10 local karma, trust/transparency qualification or monthly megathread use, and post approval. Current App Pile PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. -- `r/MacOS`: Saturday-only UTC, no bots, and limited to Mac App Store apps or reputable established GitHub repositories. Hold unless moderators confirm CodePilot qualifies or the Mac app is in the macOS App Store. +- `r/MacOS`: Saturday-only from 00:00 through 23:59 UTC, one promotional post per user per week, explicit developer/affiliate disclosure, useful context, and no low-effort link drop. GitHub repositories are allowed but will be scanned, so any linked repository must be approved, accessible, and security-ready. - `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; projects under 3 months old belong only in the current New Project Megathread, and any maker-authored mention still needs moderator and maintainer approval. - `r/ClaudeAI`: do not promote this Codex-focused beta because it does not meet the community's Claude-built/Claude-focused showcase gate; eligible showcases also require more than 50 account karma. - Hacker News: a maintainer should write the actual comment manually because HN asks users not to post generated or AI-edited comments. @@ -383,28 +383,37 @@ It includes a token-protected local gateway and uses a user-owned Cloudflare Tun Would you prefer that I hold off until public docs and production readiness are stronger? If a text-only architecture/security feedback post would be acceptable later, I would disclose that I am the maker, avoid pricing language, and avoid posting a promotional link unless you approve it. ``` -## Draft 9: `r/MacOS` Moderator Note Only +## Draft 9: `r/MacOS` Developer Saturday Post Posting target: `r/MacOS` -Approval status: hold. Moderator approval and maintainer approval required -before any post or comment. Do not post from an automated account. +Approval status: needs maintainer approval before posting. Saturday only from +00:00 through 23:59 UTC; maximum one promotional post per user per week. Do not +post from an automated account. -Reason for hold: -- Current rules allow Saturday UTC promotion for Mac App Store apps or reputable - established GitHub repositories. -- CodePilot is source-available and public, but whether the repository is - "reputable" and "established" is a moderator judgment. -- The launch plan forbids public posting without maintainer approval. +Posting gates: +- Explicitly disclose the maker relationship. +- Explain what the app does and how it helps Mac users; do not drop only a link. +- Link only to an approved, accessible, security-ready GitHub repository or + official distribution page. GitHub links are subject to GitHub-Guard scanning. +- Re-check the live policy immediately before posting. -Moderator note draft: +Title: + +```text +I built a Mac + iPhone companion for checking and steering Codex sessions away from my desk +``` + +No-link body draft: ```text -Hi mods, I am the maker of CodePilot, a beta Mac menu bar app plus iPhone companion for people running Codex from a Mac they control. +I am the maker of CodePilot, a beta Mac menu bar app plus iPhone companion for people who run Codex from a Mac they control. + +The Mac remains the source of truth for Codex auth, project files, account state, and active sessions. The menu bar app shows local account and usage state, while a token-protected loopback gateway gives the iPhone client narrow access to session status, file uploads, notifications, and controls for steering or stopping an active turn. For the current public-beta path, the user connects the phone through a Cloudflare Tunnel they control; the provider login stays on the Mac. -I saw the Saturday-only promotion rule and the requirement that non-App-Store apps use reputable, established GitHub repositories. CodePilot is source-available and public, but I do not want to assume it qualifies. +I am looking for setup and trust feedback, not upvotes. For Mac users who run coding agents locally: which part would make you stop during setup—the gateway token, tunnel, iPhone pairing, or the idea of remote turn control? What security or recovery documentation would you expect before trying the beta? -Would a Saturday text-only beta-feedback post be acceptable if it discloses that I am the maker, avoids pricing language, avoids asking for votes, and focuses on setup/security feedback from Mac users? If not, I will hold off or use r/macapps if that is the better fit. +I have left out the link in this draft until the public repository or distribution page has passed launch review. If approved at posting time, I will add only the direct official link. ``` ## Hacker News / Show HN Outline From 6041385c25169382325bef7f402650ce5401d098 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 06:11:45 +0200 Subject: [PATCH 25/57] docs: require manual posting in codex community --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index ead37de2..a978440d 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -39,7 +39,7 @@ Operational rules for this queue: | Community | Fit | Rule summary | Recommendation | | --- | --- | --- | --- | | `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | -| `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Search-visible rules require posts to be directly related to Codex and avoid low-effort rants. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. Use text-only feedback framing unless mods approve a beta link. | +| `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | | `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | | `r/swift` | Medium | Self-promotion must be under 5% of the account's posts and comments in the subreddit. It is not allowed with fewer than 5 posts/comments in the sub or when the account is under 2 months old. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after the account age, activity, and contribution-ratio thresholds are met. | @@ -55,7 +55,7 @@ Operational rules for this queue: - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. - `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. -- `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply. +- `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. - `r/macapps`: requires 10 local karma, trust/transparency qualification or monthly megathread use, and post approval. Current App Pile PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. @@ -135,6 +135,7 @@ Approval status: moderator approval and maintainer approval required before post Rule notes: - Must be directly related to OpenAI Codex tools. - Keep it high-signal and feedback-focused. +- A maintainer must post manually; the subreddit explicitly prohibits bots. - New-account subreddit karma restrictions may block comments/posts. - Disclose maker relationship. From 01c5f4827a6b43a4ac0911001863a05211eab832 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 06:52:23 +0200 Subject: [PATCH 26/57] docs: expand community beta drafts --- .../2026-07-01-codepilot-beta-drafts.md | 50 ++++++++++++++++++- ops/agents/escalations/community-drafts.md | 15 ++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 ops/agents/escalations/community-drafts.md diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index a978440d..4f490697 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -39,6 +39,7 @@ Operational rules for this queue: | Community | Fit | Rule summary | Recommendation | | --- | --- | --- | --- | | `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | +| `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | | `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | @@ -55,6 +56,7 @@ Operational rules for this queue: - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. - `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. +- `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. @@ -417,6 +419,49 @@ I am looking for setup and trust feedback, not upvotes. For Mac users who run co I have left out the link in this draft until the public repository or distribution page has passed launch review. If approved at posting time, I will add only the direct official link. ``` +## Draft 10: `r/TestFlight` Beta Post + +Posting target: `r/TestFlight` + +Approval status: hold until a maintainer approves final posting and a public +TestFlight link has been verified. The placeholder below must never be posted. + +Posting gates: +- The title must be the app name with only an optional short message; do not use + tags such as `[Tester]` or `[Recruiting]`. +- Select the `iOS` post flair. +- Include only the direct Apple TestFlight link. Do not include the repository, + website, redirects, or other external links. +- Keep the body and follow-up comments focused on the TestFlight app. +- Do not repost routine updates; add them as comments on the original post. +- Do not request personal information. Ask testers to use approved feedback + channels and remind them not to share tokens, private logs, or credentials. + +Title: + +```text +CodePilot - iPhone companion for Codex sessions on your Mac +``` + +Body: + +```text +I am the maker of CodePilot. This is a beta iPhone companion for people who already run Codex on a Mac they control. + +The iPhone app connects to CodePilot's token-protected gateway on the Mac. It can show session and account status, browse threads, upload files, receive turn-finished notifications, and steer or stop an active turn. Codex credentials and project files remain on the Mac. For remote access in the current beta, testers use a Cloudflare Tunnel they control. + +I am especially looking for feedback on: + +- whether the Mac-to-iPhone connection setup is understandable +- whether token and tunnel recovery steps are clear +- whether session controls are useful away from the desk +- which setup step would make you abandon the beta + +Please do not send gateway tokens, credentials, private hostnames, private logs, or project files in Reddit messages or comments. Use only sanitized reproduction steps in feedback. + +TestFlight: [APPROVED_PUBLIC_TESTFLIGHT_LINK] +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. @@ -482,4 +527,7 @@ Escalation needed: yes/no ## Escalation Status -No escalation file was created for this draft packet. Account creation, moderator messages, final post approval, and any public posting remain human approval steps, but none block draft preparation. +An escalation note was created at +`ops/agents/escalations/community-drafts.md`. Draft preparation is complete, but +all public posting remains blocked on maintainer approval. The `r/TestFlight` +draft additionally requires a verified public TestFlight link. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md new file mode 100644 index 00000000..0776af23 --- /dev/null +++ b/ops/agents/escalations/community-drafts.md @@ -0,0 +1,15 @@ +# Community Drafts Escalation + +Status: maintainer action required before any public posting. + +- Review and approve the proposed first community placement and final copy. +- Confirm that the `codepilotios` identity is approved and eligible under the + target community's account-age, karma, and participation rules. Do not create + an account solely to satisfy this request. +- For `r/TestFlight`, provide or approve a verified public Apple TestFlight link; + the draft must contain no other link. +- If choosing `r/codex`, `r/OpenAI`, `r/macapps`, or another moderator-first + target, send the prepared modmail manually and wait for approval. + +No post, comment, profile change, account action, or moderator message was made +by the community-drafts agent. From 29044893c4eefab4e087984b2addd977bc0d5920 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 08:48:53 +0200 Subject: [PATCH 27/57] docs: expand community rule research --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 6 ++++++ ops/agents/escalations/community-drafts.md | 3 +++ 2 files changed, 9 insertions(+) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 4f490697..4a0fb004 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -40,10 +40,13 @@ Operational rules for this queue: | --- | --- | --- | --- | | `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | +| `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | +| `r/iosapps` | Low / ineligible for this beta | Requires 10 local karma, official App Store or TestFlight distribution, infrequent promotion, disclosure, and either trust/transparency qualification or the monthly megathread. Its current rules prohibit generative-AI and AI-wrapped apps when AI is a core feature. Source: | Do not promote CodePilot here. The beta is built specifically around Codex workflows and therefore conflicts with the current AI-app exclusion, regardless of distribution readiness. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | | `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | | `r/swift` | Medium | Self-promotion must be under 5% of the account's posts and comments in the subreddit. It is not allowed with fewer than 5 posts/comments in the sub or when the account is under 2 months old. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after the account age, activity, and contribution-ratio thresholds are met. | +| `r/SwiftUI` | Low-medium for technical discussion only | Posts must be specifically about SwiftUI rather than general app feedback. App promotion is permitted only when source code is also provided, preferably through GitHub; low-effort showcases and questions without relevant code are discouraged. Source: | Do not use for tester recruitment. Consider only a focused SwiftUI implementation discussion after the relevant source, code excerpt, and public repository have passed launch review. Maintainer approval is still required. | | `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and the July 2026 App Pile megathread. Rule/sidebar snippets require 10 local karma, megathread promotion unless the account qualifies through trust/transparency, post approval, and PCP-style promotion. Recent megathreads require pricing and link fields. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | | `r/MacOS` | Medium | The March 2026 Developer Saturday policy permits one self-promotional post per user each Saturday from 00:00 through 23:59 UTC. It explicitly welcomes accessible GitHub repositories, which are scanned by GitHub-Guard, and requires affiliation disclosure, useful context, and more than a low-effort link drop. Source: | Candidate Saturday placement after maintainer approval. Use a transparent, context-rich beta-feedback post and link only to an approved, accessible, security-ready repository or distribution page. | @@ -57,9 +60,12 @@ Operational rules for this queue: - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. - `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. +- `r/appledevelopers`: do not duplicate the beta-recruitment post; its rules direct TestFlight apps to `r/TestFlight`. Any later technical post must be substantive, limited, transparent, and maintainer-approved. +- `r/iosapps`: do not promote this beta while the community's AI-core-app exclusion applies. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. +- `r/SwiftUI`: no tester-recruitment post. A later technical discussion requires a concrete SwiftUI question, relevant source code, an approved public repository if linked, and maintainer approval. - `r/macapps`: requires 10 local karma, trust/transparency qualification or monthly megathread use, and post approval. Current App Pile PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. - `r/MacOS`: Saturday-only from 00:00 through 23:59 UTC, one promotional post per user per week, explicit developer/affiliate disclosure, useful context, and no low-effort link drop. GitHub repositories are allowed but will be scanned, so any linked repository must be approved, accessible, and security-ready. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 0776af23..ab68b4b8 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -8,6 +8,9 @@ Status: maintainer action required before any public posting. an account solely to satisfy this request. - For `r/TestFlight`, provide or approve a verified public Apple TestFlight link; the draft must contain no other link. +- Do not cross-post the beta recruitment to `r/appledevelopers`, which directs + TestFlight apps to `r/TestFlight`; do not promote it in `r/iosapps` while that + community excludes apps whose core feature is generative AI. - If choosing `r/codex`, `r/OpenAI`, `r/macapps`, or another moderator-first target, send the prepared modmail manually and wait for approval. From 4653375c16b2cd38eb22e2c4dbb0e49c66f6fd92 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 10:34:45 +0200 Subject: [PATCH 28/57] docs: correct iOSApps promotion route --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 4 ++-- ops/agents/escalations/community-drafts.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 4a0fb004..5ee927fb 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -41,7 +41,7 @@ Operational rules for this queue: | `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | -| `r/iosapps` | Low / ineligible for this beta | Requires 10 local karma, official App Store or TestFlight distribution, infrequent promotion, disclosure, and either trust/transparency qualification or the monthly megathread. Its current rules prohibit generative-AI and AI-wrapped apps when AI is a core feature. Source: | Do not promote CodePilot here. The beta is built specifically around Codex workflows and therefore conflicts with the current AI-app exclusion, regardless of distribution readiness. | +| `r/iosapps` | Low / hold for approved metadata | Requires 10 local karma for main-feed eligibility, official App Store or TestFlight distribution, infrequent promotion, disclosure, and either trust/transparency qualification or the monthly megathread. The July 2026 policy excludes AI-first apps from the main feed and routes generative-AI/AI-wrapper promotion to the App Shelf, whose required ABC format includes pricing and a direct App Store link. Sources: and | Do not use the main feed. Hold the megathread placement until a maintainer approves distribution eligibility, exact pricing/status language, and the direct store link; this run may not invent or imply those details. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | | `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | @@ -61,7 +61,7 @@ Operational rules for this queue: - `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/appledevelopers`: do not duplicate the beta-recruitment post; its rules direct TestFlight apps to `r/TestFlight`. Any later technical post must be substantive, limited, transparent, and maintainer-approved. -- `r/iosapps`: do not promote this beta while the community's AI-core-app exclusion applies. +- `r/iosapps`: do not use the main feed for this AI-first beta. The monthly App Shelf is the apparent promotion route, but its ABC format requires pricing and a direct App Store link; hold until a maintainer approves those fields and confirms current distribution eligibility. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index ab68b4b8..3618a5f5 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -9,8 +9,9 @@ Status: maintainer action required before any public posting. - For `r/TestFlight`, provide or approve a verified public Apple TestFlight link; the draft must contain no other link. - Do not cross-post the beta recruitment to `r/appledevelopers`, which directs - TestFlight apps to `r/TestFlight`; do not promote it in `r/iosapps` while that - community excludes apps whose core feature is generative AI. + TestFlight apps to `r/TestFlight`. Do not use the `r/iosapps` main feed for + this AI-first beta; its monthly App Shelf requires approved pricing/status + language and a direct App Store link before a compliant draft can be posted. - If choosing `r/codex`, `r/OpenAI`, `r/macapps`, or another moderator-first target, send the prepared modmail manually and wait for approval. From 61de7e2cf6afddd7cbba08335ddc1bcad803c120 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 11:25:01 +0200 Subject: [PATCH 29/57] docs: add GenAI community beta draft --- .../2026-07-01-codepilot-beta-drafts.md | 39 +++++++++++++++++++ ops/agents/escalations/community-drafts.md | 3 ++ 2 files changed, 42 insertions(+) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 5ee927fb..ee9274a1 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -42,6 +42,7 @@ Operational rules for this queue: | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | | `r/iosapps` | Low / hold for approved metadata | Requires 10 local karma for main-feed eligibility, official App Store or TestFlight distribution, infrequent promotion, disclosure, and either trust/transparency qualification or the monthly megathread. The July 2026 policy excludes AI-first apps from the main feed and routes generative-AI/AI-wrapper promotion to the App Shelf, whose required ABC format includes pricing and a direct App Store link. Sources: and | Do not use the main feed. Hold the megathread placement until a maintainer approves distribution eligibility, exact pricing/status language, and the direct store link; this run may not invent or imply those details. | +| `r/GenAiApps` | Medium-high | The community is specifically for generative-AI apps. Current rules allow self-promotion when it provides value and context, require an appropriate flair, prohibit spam/duplicate or low-effort posts, require context for links, and prohibit unsafe links or personal information. Source: | Good text-first beta-feedback candidate after maintainer approval. Use the `iOS` flair, disclose the maker relationship, explain the Codex-specific workflow, and include no link until the public beta URL is approved. Re-check recent posts and flair options immediately before posting. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | | `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | @@ -62,6 +63,7 @@ Operational rules for this queue: - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/appledevelopers`: do not duplicate the beta-recruitment post; its rules direct TestFlight apps to `r/TestFlight`. Any later technical post must be substantive, limited, transparent, and maintainer-approved. - `r/iosapps`: do not use the main feed for this AI-first beta. The monthly App Shelf is the apparent promotion route, but its ABC format requires pricing and a direct App Store link; hold until a maintainer approves those fields and confirms current distribution eligibility. +- `r/GenAiApps`: general rules allow valuable self-promotion, but a maintainer must still approve the final post. Use the `iOS` flair, provide useful context, avoid repeat or low-effort promotion, and include no link until the public beta URL is approved. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. @@ -468,6 +470,43 @@ Please do not send gateway tokens, credentials, private hostnames, private logs, TestFlight: [APPROVED_PUBLIC_TESTFLIGHT_LINK] ``` +## Draft 11: `r/GenAiApps` Text-First Beta Feedback Post + +Posting target: `r/GenAiApps` + +Approval status: needs maintainer approval before posting. Use the `iOS` flair +and do not add a link until the public beta URL is verified and approved. + +Rule notes: +- Keep the post focused on a generative-AI app or workflow. +- Self-promotion must provide useful context rather than a low-effort link drop. +- Avoid duplicate promotion and use an appropriate flair. +- If a link is approved later, explain its relevance and verify that it is safe. +- Do not include personal information, private logs, tokens, or credentials. + +Title: + +```text +I built an iPhone companion for Codex sessions running on your Mac - looking for beta workflow feedback +``` + +Body: + +```text +I am the maker of CodePilot. It is a beta Mac + iPhone companion for people who already run Codex on a Mac they control. + +The idea is to leave the coding-agent session, credentials, and project files on the Mac while giving the iPhone a focused view of session status, threads, file uploads, notifications, and controls to steer or stop an active turn. The Mac runs a token-protected local gateway; remote access in the current beta uses a Cloudflare Tunnel controlled by the tester. + +I am trying to learn whether this solves a real generative-AI workflow problem or just moves a terminal-shaped problem onto a smaller screen. The feedback I would value most is: + +- when you would actually check or control a coding-agent session from your phone +- which remote actions feel useful versus risky +- whether the Mac gateway and iPhone connection model is understandable +- what trust or setup concern would stop you from trying the beta + +This is a feedback request, not an upvote request. Please do not share credentials, gateway tokens, private hostnames, private logs, or project files in comments or messages. I will add an approved beta link only when public distribution is ready. +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 3618a5f5..1bc04b89 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -12,6 +12,9 @@ Status: maintainer action required before any public posting. TestFlight apps to `r/TestFlight`. Do not use the `r/iosapps` main feed for this AI-first beta; its monthly App Shelf requires approved pricing/status language and a direct App Store link before a compliant draft can be posted. +- `r/GenAiApps` is a text-first alternative whose rules allow contextual, + valuable self-promotion. Approve the prepared copy and `iOS` flair before a + human posts it; do not add a beta link until that URL is verified and approved. - If choosing `r/codex`, `r/OpenAI`, `r/macapps`, or another moderator-first target, send the prepared modmail manually and wait for approval. From 436ba76f559a615b4c20c381e7853f12a3feda60 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 12:12:40 +0200 Subject: [PATCH 30/57] docs: add community promotion holds --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 4 ++++ ops/agents/escalations/community-drafts.md | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index ee9274a1..39478c3a 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -42,7 +42,9 @@ Operational rules for this queue: | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | | `r/iosapps` | Low / hold for approved metadata | Requires 10 local karma for main-feed eligibility, official App Store or TestFlight distribution, infrequent promotion, disclosure, and either trust/transparency qualification or the monthly megathread. The July 2026 policy excludes AI-first apps from the main feed and routes generative-AI/AI-wrapper promotion to the App Shelf, whose required ABC format includes pricing and a direct App Store link. Sources: and | Do not use the main feed. Hold the megathread placement until a maintainer approves distribution eligibility, exact pricing/status language, and the direct store link; this run may not invent or imply those details. | +| `r/iPhoneApps` | Low / hold until genuine participation | The live rules allow recommending your own iPhone app only when it is relevant to an existing discussion, clearly disclosed, and useful. Shared apps must be available on the Apple App Store and include details such as description and cost. A 2026-07-16 moderator announcement removed the Developer flair and says pure promotional drops will be removed; developers should participate and contribute before mentioning their app in context. Sources: and | Do not prepare a standalone launch post or use a new promotion-only account. Revisit only after the approved identity has genuine community participation and CodePilot has an approved App Store listing and status language. Use modmail, not direct moderator contact, if eligibility remains unclear. | | `r/GenAiApps` | Medium-high | The community is specifically for generative-AI apps. Current rules allow self-promotion when it provides value and context, require an appropriate flair, prohibit spam/duplicate or low-effort posts, require context for links, and prohibit unsafe links or personal information. Source: | Good text-first beta-feedback candidate after maintainer approval. Use the `iOS` flair, disclose the maker relationship, explain the Codex-specific workflow, and include no link until the public beta URL is approved. Re-check recent posts and flair options immediately before posting. | +| `r/vibecoding` | Low / externally gated | CodePilot counts as a developer tool, and the live rules require developer-tool makers to obtain approval by joining the community on X, posting the tool there, and waiting for a moderator DM before making one introduction post. The subreddit also rejects fully generated low-effort content and requires educational detail for project posts. Sources: and | Hold. This run may not create or mutate accounts or post on non-GitHub external systems, so it cannot complete the X approval gate. Do not disguise CodePilot promotion as general discussion or a project breakdown. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | | `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | @@ -63,7 +65,9 @@ Operational rules for this queue: - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/appledevelopers`: do not duplicate the beta-recruitment post; its rules direct TestFlight apps to `r/TestFlight`. Any later technical post must be substantive, limited, transparent, and maintainer-approved. - `r/iosapps`: do not use the main feed for this AI-first beta. The monthly App Shelf is the apparent promotion route, but its ABC format requires pricing and a direct App Store link; hold until a maintainer approves those fields and confirms current distribution eligibility. +- `r/iPhoneApps`: do not make a standalone promotional post. The Developer flair was removed on 2026-07-16, self-mentions must arise from genuine participation and add value, and shared apps must be available on the Apple App Store with approved cost/status details. Use modmail rather than contacting moderators directly if a later contextual mention is uncertain. - `r/GenAiApps`: general rules allow valuable self-promotion, but a maintainer must still approve the final post. Use the `iOS` flair, provide useful context, avoid repeat or low-effort promotion, and include no link until the public beta URL is approved. +- `r/vibecoding`: do not post or prepare a disguised discussion placement. Developer-tool promotion requires prior approval through an X community post and moderator DM; satisfying that gate requires a maintainer-controlled external account action that this agent is not authorized to perform. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 1bc04b89..8d9ba54b 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -15,6 +15,12 @@ Status: maintainer action required before any public posting. - `r/GenAiApps` is a text-first alternative whose rules allow contextual, valuable self-promotion. Approve the prepared copy and `iOS` flair before a human posts it; do not add a beta link until that URL is verified and approved. +- Do not use `r/iPhoneApps` for a standalone launch post. Its Developer flair was + removed on 2026-07-16, and maker mentions now require genuine participation, + contextual value, an App Store listing, and approved status/cost details. +- Hold `r/vibecoding`: its developer-tool approval flow requires a public X + community post and moderator DM. That external account action is outside this + agent's authority and should not be performed solely for promotion. - If choosing `r/codex`, `r/OpenAI`, `r/macapps`, or another moderator-first target, send the prepared modmail manually and wait for approval. From a9a25fa614947a89f0d15bd202ae2c4339bc31b2 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 12:59:27 +0200 Subject: [PATCH 31/57] docs: add GitHub community beta draft --- .../2026-07-01-codepilot-beta-drafts.md | 27 +++++++++++++++++++ ops/agents/escalations/community-drafts.md | 3 +++ 2 files changed, 30 insertions(+) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 39478c3a..0c691b53 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -54,6 +54,7 @@ Operational rules for this queue: | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | | `r/MacOS` | Medium | The March 2026 Developer Saturday policy permits one self-promotional post per user each Saturday from 00:00 through 23:59 UTC. It explicitly welcomes accessible GitHub repositories, which are scanned by GitHub-Guard, and requires affiliation disclosure, useful context, and more than a low-effort link drop. Source: | Candidate Saturday placement after maintainer approval. Use a transparent, context-rich beta-feedback post and link only to an approved, accessible, security-ready repository or distribution page. | | `r/selfhosted` | Low-medium later | Mobile apps are allowed only as companions to a self-hosted backend. Promoted apps must be production-ready and documented. Projects under 3 months old may be posted only in the current New Project Megathread; Wednesday has a separate exception for dashboards and tools that help self-hosters. Source: | Hold for now. CodePilot has a local gateway and user-owned tunnel, but this is not primarily a general self-hosted server product; ask moderators only after public docs and production readiness are stronger. | +| `r/github` self-promotion megathread | Medium | The designated recurring self-promotion thread accepts tools, libraries, and side projects hosted on GitHub. Comments should include a short project description, a GitHub repository link, and optionally the stack, main features, or contribution context. Source: | Candidate only after the public repository URL is approved, accessible, and privacy-audited. Use the megathread rather than a standalone promotional post, disclose the maker relationship, and ask for repository/setup feedback rather than votes. | | `r/ClaudeAI` | Low | Project showcases must be built with Claude/Claude Code or specifically for Claude by the poster, be free to try, explain how Claude helped, use minimal promotional language, and come from an account with more than 50 karma. Source: | Do not post CodePilot promotion because this Codex-focused beta does not meet the stated Claude-project relevance gate. Only answer organically if directly relevant and approved by a maintainer. | | Hacker News / Show HN | Medium | Show HN is for something people can try; HN asks submitters not to use it primarily for promotion and not to post generated or AI-edited comments. Sources: and | Human rewrite required. Prepare outline only; do not paste AI-generated text. | | Product Hunt | Medium later | Product Hunt recommends clear product pages, maker first comment, feedback requests rather than upvote requests, and warns against spamming communities where you have not been active. Sources: and | Not a first beta move. Requires account, launch assets, and pricing/status decisions, so hold for human approval. | @@ -76,6 +77,7 @@ Operational rules for this queue: - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. - `r/MacOS`: Saturday-only from 00:00 through 23:59 UTC, one promotional post per user per week, explicit developer/affiliate disclosure, useful context, and no low-effort link drop. GitHub repositories are allowed but will be scanned, so any linked repository must be approved, accessible, and security-ready. - `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; projects under 3 months old belong only in the current New Project Megathread, and any maker-authored mention still needs moderator and maintainer approval. +- `r/github`: use only the designated self-promotion megathread and only after a maintainer approves an accessible, privacy-audited public repository URL. The comment must include that GitHub link, so a no-link placeholder must not be posted. - `r/ClaudeAI`: do not promote this Codex-focused beta because it does not meet the community's Claude-built/Claude-focused showcase gate; eligible showcases also require more than 50 account karma. - Hacker News: a maintainer should write the actual comment manually because HN asks users not to post generated or AI-edited comments. - Product Hunt: hold until account, maker identity, screenshots, launch URL, and current beta/pricing status are approved. @@ -511,6 +513,31 @@ I am trying to learn whether this solves a real generative-AI workflow problem o This is a feedback request, not an upvote request. Please do not share credentials, gateway tokens, private hostnames, private logs, or project files in comments or messages. I will add an approved beta link only when public distribution is ready. ``` +## Draft 12: `r/github` Self-Promotion Megathread Comment + +Posting target: the current designated `r/github` self-promotion megathread. + +Approval status: hold until a maintainer approves final posting and an +accessible public GitHub repository URL has passed the privacy audit. The +placeholder below must never be posted. + +Rule notes: +- Use the designated self-promotion megathread, not a standalone feed post. +- Include a short description and direct GitHub repository link. +- Add useful stack, feature, or contribution context. +- Disclose the maker relationship and do not request stars or upvotes. +- Re-check that the thread remains the current promotion route before posting. + +```text +Hi, I am the maker of CodePilot, a beta Mac + iPhone companion for people who run Codex from a Mac they control. + +The project has three parts: a Mac menu bar app for local account and usage state, a token-protected local gateway, and a SwiftUI iPhone client for session status, file uploads, notifications, and controls to steer or stop an active turn. The Mac remains the source of truth for Codex credentials, project files, and active sessions. For the current public-beta remote path, testers connect through a Cloudflare Tunnel they control. + +I am sharing the repository for technical and setup feedback, not asking for stars or votes. The feedback I would value most is whether the trust boundary is understandable, whether the setup documentation makes the gateway and tunnel responsibilities clear, and which parts of the repository should be easier to audit or contribute to. + +GitHub: [APPROVED_PUBLIC_GITHUB_REPOSITORY_URL] +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 8d9ba54b..e4e372f2 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -21,6 +21,9 @@ Status: maintainer action required before any public posting. - Hold `r/vibecoding`: its developer-tool approval flow requires a public X community post and moderator DM. That external account action is outside this agent's authority and should not be performed solely for promotion. +- For `r/github`, approve an accessible, privacy-audited public repository URL + before using the prepared self-promotion megathread comment. Do not post the + placeholder or use a standalone feed post. - If choosing `r/codex`, `r/OpenAI`, `r/macapps`, or another moderator-first target, send the prepared modmail manually and wait for approval. From 2354f0306f31098a61805fa086037775912a0422 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 14:40:00 +0200 Subject: [PATCH 32/57] docs: add SideProject beta draft --- .../2026-07-01-codepilot-beta-drafts.md | 34 ++++++++++++++++++- ops/agents/escalations/community-drafts.md | 3 ++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 0c691b53..a000f496 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -46,7 +46,7 @@ Operational rules for this queue: | `r/GenAiApps` | Medium-high | The community is specifically for generative-AI apps. Current rules allow self-promotion when it provides value and context, require an appropriate flair, prohibit spam/duplicate or low-effort posts, require context for links, and prohibit unsafe links or personal information. Source: | Good text-first beta-feedback candidate after maintainer approval. Use the `iOS` flair, disclose the maker relationship, explain the Codex-specific workflow, and include no link until the public beta URL is approved. Re-check recent posts and flair options immediately before posting. | | `r/vibecoding` | Low / externally gated | CodePilot counts as a developer tool, and the live rules require developer-tool makers to obtain approval by joining the community on X, posting the tool there, and waiting for a moderator DM before making one introduction post. The subreddit also rejects fully generated low-effort content and requires educational detail for project posts. Sources: and | Hold. This run may not create or mutate accounts or post on non-GitHub external systems, so it cannot complete the X approval gate. Do not disguise CodePilot promotion as general discussion or a project breakdown. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | -| `r/SideProject` | Medium-high | Community is for sharing and receiving constructive feedback on side projects. Current posts show beta/product feedback asks are common, and recent discussion warns that burying links in comments may be a rule-sensitive pattern. Sources: and | Good standalone beta feedback post after maintainer approval. Keep it story/feedback-first, avoid engagement manipulation, and use only approved links if links are allowed. | +| `r/SideProject` | Medium-high | The live About page currently shows no community-specific rules beyond Reddit-wide rules and says anyone may post. Project showcases and beta-feedback requests are common, and a current self-promotion thread explicitly invites projects and feedback. A recent high-engagement community discussion objects to promotion-only accounts and link drops. Sources: , , , and | Prefer the current self-promotion thread over a standalone post. Use the no-link, feedback-specific comment draft after maintainer approval, and re-check the thread and live About page immediately before posting. | | `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | | `r/swift` | Medium | Self-promotion must be under 5% of the account's posts and comments in the subreddit. It is not allowed with fewer than 5 posts/comments in the sub or when the account is under 2 months old. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after the account age, activity, and contribution-ratio thresholds are met. | | `r/SwiftUI` | Low-medium for technical discussion only | Posts must be specifically about SwiftUI rather than general app feedback. App promotion is permitted only when source code is also provided, preferably through GitHub; low-effort showcases and questions without relevant code are discouraged. Source: | Do not use for tester recruitment. Consider only a focused SwiftUI implementation discussion after the relevant source, code excerpt, and public repository have passed launch review. Maintainer approval is still required. | @@ -70,6 +70,7 @@ Operational rules for this queue: - `r/GenAiApps`: general rules allow valuable self-promotion, but a maintainer must still approve the final post. Use the `iOS` flair, provide useful context, avoid repeat or low-effort promotion, and include no link until the public beta URL is approved. - `r/vibecoding`: do not post or prepare a disguised discussion placement. Developer-tool promotion requires prior approval through an X community post and moderator DM; satisfying that gate requires a maintainer-controlled external account action that this agent is not authorized to perform. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. +- `r/SideProject`: the live About page currently lists no community-specific rules, but community sentiment strongly disfavors promotion-only participation. Prefer the current self-promotion thread, post manually only after maintainer approval, disclose the maker relationship, and use the no-link feedback draft rather than a standalone launch post. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. - `r/SwiftUI`: no tester-recruitment post. A later technical discussion requires a concrete SwiftUI question, relevant source code, an approved public repository if linked, and maintainer approval. @@ -538,6 +539,37 @@ I am sharing the repository for technical and setup feedback, not asking for sta GitHub: [APPROVED_PUBLIC_GITHUB_REPOSITORY_URL] ``` +## Draft 13: `r/SideProject` Self-Promotion Thread Comment + +Posting target: the current `r/SideProject` self-promotion thread. + +Approval status: needs maintainer approval before posting. Post manually from an +approved identity; do not use an automated or promotion-only account. + +Rule and community notes: +- The live About page currently lists no community-specific posting rules, so + Reddit-wide authenticity and spam rules still apply. +- Prefer a current thread that explicitly invites self-promotion rather than a + standalone launch post. +- Disclose the maker relationship and ask for specific product feedback. +- Use the no-link version unless a direct public beta URL is separately approved. +- Do not ask for upvotes, stars, follows, direct messages, or personal data. +- Re-check the live About page and target thread immediately before posting. + +```text +Hi, I am the maker of CodePilot. It is a beta Mac + iPhone companion for people who run Codex sessions on a Mac they control. + +I built it around a specific problem: a coding-agent turn can keep running after I leave my desk, but I do not want to move provider credentials or project files to another hosted service. CodePilot leaves those on the Mac. A token-protected local gateway gives the iPhone client narrow access to session status, threads, file uploads, notifications, and controls to steer or stop an active turn. The current public-beta remote path uses a Cloudflare Tunnel controlled by the tester. + +I am looking for blunt feedback on the idea before broader distribution: + +- Is checking or steering a coding-agent session from a phone a real workflow, or just a smaller terminal-shaped distraction? +- Does the Mac gateway plus user-owned tunnel model feel understandable? +- Which part would stop you from trying the beta: setup effort, token handling, remote access, or something else? + +This is a feedback request, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, logs, or project files in replies. +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index e4e372f2..797df286 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -24,6 +24,9 @@ Status: maintainer action required before any public posting. - For `r/github`, approve an accessible, privacy-audited public repository URL before using the prepared self-promotion megathread comment. Do not post the placeholder or use a standalone feed post. +- For `r/SideProject`, approve the prepared no-link comment and have a human use + the current self-promotion thread. Do not use a standalone launch post or a + promotion-only account; re-check the live About page and thread first. - If choosing `r/codex`, `r/OpenAI`, `r/macapps`, or another moderator-first target, send the prepared modmail manually and wait for approval. From ea88240632f0175d3554e1481edd0237d04760d2 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 14:41:13 +0200 Subject: [PATCH 33/57] docs: hold SideProject standalone post --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index a000f496..c5aece64 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -185,14 +185,18 @@ The feedback I am looking for: This is a beta feedback request, not an upvote request. I am trying to make the beta safer and clearer before broader distribution. ``` -## Draft 3: `r/SideProject` Standalone Feedback Post +## Draft 3: `r/SideProject` Standalone Feedback Post (Held Alternative) Posting target: `r/SideProject` -Approval status: needs maintainer approval before posting. +Approval status: hold. Prefer Draft 13 in a current self-promotion thread. Use +this standalone alternative only if the thread is unavailable and a maintainer +explicitly approves standalone placement after re-checking the live community. Rule notes: - Fit is strongest when framed as a specific side-project feedback request. +- Current community sentiment strongly disfavors promotion-only participation + and link drops, even though the live About page lists no specific rules. - Do not ask friends, testers, or communities to upvote. - Include only an approved public link if links are allowed at posting time; otherwise ask for replies and share the approved beta link only after maintainer approval. From 045a02a13774013bdd881adb60e6acbdaf7e1629 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 15:28:27 +0200 Subject: [PATCH 34/57] docs: add AI agents community beta draft --- .../2026-07-01-codepilot-beta-drafts.md | 34 +++++++++++++++++++ ops/agents/escalations/community-drafts.md | 4 +++ 2 files changed, 38 insertions(+) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index c5aece64..c190b3da 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -44,6 +44,7 @@ Operational rules for this queue: | `r/iosapps` | Low / hold for approved metadata | Requires 10 local karma for main-feed eligibility, official App Store or TestFlight distribution, infrequent promotion, disclosure, and either trust/transparency qualification or the monthly megathread. The July 2026 policy excludes AI-first apps from the main feed and routes generative-AI/AI-wrapper promotion to the App Shelf, whose required ABC format includes pricing and a direct App Store link. Sources: and | Do not use the main feed. Hold the megathread placement until a maintainer approves distribution eligibility, exact pricing/status language, and the direct store link; this run may not invent or imply those details. | | `r/iPhoneApps` | Low / hold until genuine participation | The live rules allow recommending your own iPhone app only when it is relevant to an existing discussion, clearly disclosed, and useful. Shared apps must be available on the Apple App Store and include details such as description and cost. A 2026-07-16 moderator announcement removed the Developer flair and says pure promotional drops will be removed; developers should participate and contribute before mentioning their app in context. Sources: and | Do not prepare a standalone launch post or use a new promotion-only account. Revisit only after the approved identity has genuine community participation and CodePilot has an approved App Store listing and status language. Use modmail, not direct moderator contact, if eligibility remains unclear. | | `r/GenAiApps` | Medium-high | The community is specifically for generative-AI apps. Current rules allow self-promotion when it provides value and context, require an appropriate flair, prohibit spam/duplicate or low-effort posts, require context for links, and prohibit unsafe links or personal information. Source: | Good text-first beta-feedback candidate after maintainer approval. Use the `iOS` flair, disclose the maker relationship, explain the Codex-specific workflow, and include no link until the public beta URL is approved. Re-check recent posts and flair options immediately before posting. | +| `r/AI_Agents` | Medium-high | The live rules permit limited self-promotion at roughly one in ten posts/comments, prohibit promotion-only accounts and low-context posts, require links to be placed in comments, and route project showcases to the weekly Project Display thread. The current weekly thread explicitly welcomes AI agents and LLM apps. Sources: and | Good agent-workflow feedback target only after the approved identity has genuine non-promotional participation. Use the weekly Project Display thread, disclose the maker relationship, and keep the first draft link-free unless a direct public beta or repository URL is separately approved. | | `r/vibecoding` | Low / externally gated | CodePilot counts as a developer tool, and the live rules require developer-tool makers to obtain approval by joining the community on X, posting the tool there, and waiting for a moderator DM before making one introduction post. The subreddit also rejects fully generated low-effort content and requires educational detail for project posts. Sources: and | Hold. This run may not create or mutate accounts or post on non-GitHub external systems, so it cannot complete the X approval gate. Do not disguise CodePilot promotion as general discussion or a project breakdown. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | | `r/SideProject` | Medium-high | The live About page currently shows no community-specific rules beyond Reddit-wide rules and says anyone may post. Project showcases and beta-feedback requests are common, and a current self-promotion thread explicitly invites projects and feedback. A recent high-engagement community discussion objects to promotion-only accounts and link drops. Sources: , , , and | Prefer the current self-promotion thread over a standalone post. Use the no-link, feedback-specific comment draft after maintainer approval, and re-check the thread and live About page immediately before posting. | @@ -68,6 +69,7 @@ Operational rules for this queue: - `r/iosapps`: do not use the main feed for this AI-first beta. The monthly App Shelf is the apparent promotion route, but its ABC format requires pricing and a direct App Store link; hold until a maintainer approves those fields and confirms current distribution eligibility. - `r/iPhoneApps`: do not make a standalone promotional post. The Developer flair was removed on 2026-07-16, self-mentions must arise from genuine participation and add value, and shared apps must be available on the Apple App Store with approved cost/status details. Use modmail rather than contacting moderators directly if a later contextual mention is uncertain. - `r/GenAiApps`: general rules allow valuable self-promotion, but a maintainer must still approve the final post. Use the `iOS` flair, provide useful context, avoid repeat or low-effort promotion, and include no link until the public beta URL is approved. +- `r/AI_Agents`: use only the current weekly Project Display thread after the approved identity has genuine participation and its self-promotion ratio is safely below one in ten. Put any separately approved link in a reply rather than the project description, provide substantive workflow context, and have a maintainer post manually. - `r/vibecoding`: do not post or prepare a disguised discussion placement. Developer-tool promotion requires prior approval through an X community post and moderator DM; satisfying that gate requires a maintainer-controlled external account action that this agent is not authorized to perform. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. - `r/SideProject`: the live About page currently lists no community-specific rules, but community sentiment strongly disfavors promotion-only participation. Prefer the current self-promotion thread, post manually only after maintainer approval, disclose the maker relationship, and use the no-link feedback draft rather than a standalone launch post. @@ -574,6 +576,38 @@ I am looking for blunt feedback on the idea before broader distribution: This is a feedback request, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, logs, or project files in replies. ``` +## Draft 14: `r/AI_Agents` Weekly Project Display Comment + +Posting target: the current `r/AI_Agents` weekly Project Display thread. + +Approval status: needs maintainer approval before posting. Hold until the +approved identity has genuine non-promotional participation and its promotion +ratio is safely below the community's one-in-ten guideline. + +Rule notes: +- Use the weekly Project Display thread rather than a standalone project post. +- Provide useful agent-workflow context; do not submit a low-effort link drop. +- Disclose the maker relationship and do not ask for votes, follows, or messages. +- Keep links out of the project description. If a direct beta or repository URL + is separately approved, place it in a reply as required by the live rules. +- Re-check the live About page and current weekly thread immediately before a + maintainer posts manually. + +```text +Hi, I am the maker of CodePilot. It is a beta Mac + iPhone companion for people who run Codex sessions on a Mac they control. + +The agent-workflow problem I am exploring is what should remain possible after you leave the terminal. CodePilot keeps the Codex session, credentials, and project files on the Mac. A token-protected local gateway gives the iPhone client narrow access to session status, threads, file uploads, notifications, and controls to steer or stop an active turn. The current public-beta remote path uses a Cloudflare Tunnel controlled by the tester. + +I would value feedback from people who run coding agents on longer tasks: + +- Which remote actions are genuinely useful: status, steering, stopping, file upload, or none of them? +- Which actions should always require a fresh confirmation on the Mac or phone? +- Does a user-owned gateway and tunnel make the trust boundary clearer, or just make setup harder? +- What audit or recovery information would you expect before connecting a phone to an active agent session? + +This is a workflow and trust feedback request, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, logs, or project files in replies. +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 797df286..ddcc34f3 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -15,6 +15,10 @@ Status: maintainer action required before any public posting. - `r/GenAiApps` is a text-first alternative whose rules allow contextual, valuable self-promotion. Approve the prepared copy and `iOS` flair before a human posts it; do not add a beta link until that URL is verified and approved. +- For `r/AI_Agents`, use only the current weekly Project Display thread after the + approved identity has genuine participation and remains safely below the + community's one-in-ten self-promotion guideline. Approve the no-link draft; + any later approved link belongs in a reply rather than the project description. - Do not use `r/iPhoneApps` for a standalone launch post. Its Developer flair was removed on 2026-07-16, and maker mentions now require genuine participation, contextual value, an App Store listing, and approved status/cost details. From 708c2915b0733b15fad2ce319fe0bb3f0d153485 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 21:14:34 +0200 Subject: [PATCH 35/57] docs: add BetaTests community draft --- .../2026-07-01-codepilot-beta-drafts.md | 45 ++++++++++++++++++- ops/agents/escalations/community-drafts.md | 4 ++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index c190b3da..b8ceb27f 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -40,6 +40,7 @@ Operational rules for this queue: | --- | --- | --- | --- | | `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | +| `r/BetaTests` | Medium | The community welcomes product beta-testing and feedback posts, but prohibits spam and low-effort submissions. Posting accounts must be at least 24 hours old and have at least 2 combined Reddit karma. The sidebar says product posts do not need moderator pre-approval. Sources: and | Useful secondary tester-recruitment target after the identity is approved and eligible. Use the specific no-link feedback draft first, disclose the maker relationship, and add only a separately approved public beta URL. Re-check the live rules immediately before posting. | | `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | | `r/iosapps` | Low / hold for approved metadata | Requires 10 local karma for main-feed eligibility, official App Store or TestFlight distribution, infrequent promotion, disclosure, and either trust/transparency qualification or the monthly megathread. The July 2026 policy excludes AI-first apps from the main feed and routes generative-AI/AI-wrapper promotion to the App Shelf, whose required ABC format includes pricing and a direct App Store link. Sources: and | Do not use the main feed. Hold the megathread placement until a maintainer approves distribution eligibility, exact pricing/status language, and the direct store link; this run may not invent or imply those details. | | `r/iPhoneApps` | Low / hold until genuine participation | The live rules allow recommending your own iPhone app only when it is relevant to an existing discussion, clearly disclosed, and useful. Shared apps must be available on the Apple App Store and include details such as description and cost. A 2026-07-16 moderator announcement removed the Developer flair and says pure promotional drops will be removed; developers should participate and contribute before mentioning their app in context. Sources: and | Do not prepare a standalone launch post or use a new promotion-only account. Revisit only after the approved identity has genuine community participation and CodePilot has an approved App Store listing and status language. Use modmail, not direct moderator contact, if eligibility remains unclear. | @@ -65,6 +66,7 @@ Operational rules for this queue: - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. - `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. +- `r/BetaTests`: no moderator pre-approval is currently required, but launch-plan maintainer approval still is. Confirm that `codepilotios` is at least 24 hours old and has at least 2 combined Reddit karma. Keep the post specific to beta testing, constructive, and non-spammy; use the no-link draft unless a public beta URL is separately approved. - `r/appledevelopers`: do not duplicate the beta-recruitment post; its rules direct TestFlight apps to `r/TestFlight`. Any later technical post must be substantive, limited, transparent, and maintainer-approved. - `r/iosapps`: do not use the main feed for this AI-first beta. The monthly App Shelf is the apparent promotion route, but its ABC format requires pricing and a direct App Store link; hold until a maintainer approves those fields and confirms current distribution eligibility. - `r/iPhoneApps`: do not make a standalone promotional post. The Developer flair was removed on 2026-07-16, self-mentions must arise from genuine participation and add value, and shared apps must be available on the Apple App Store with approved cost/status details. Use modmail rather than contacting moderators directly if a later contextual mention is uncertain. @@ -608,6 +610,45 @@ I would value feedback from people who run coding agents on longer tasks: This is a workflow and trust feedback request, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, logs, or project files in replies. ``` +## Draft 15: `r/BetaTests` Tester Feedback Post + +Posting target: `r/BetaTests` main feed. + +Approval status: needs maintainer approval before posting. The approved identity +must be at least 24 hours old and have at least 2 combined Reddit karma. Do not +create an account or manufacture activity to meet those thresholds. + +Rule notes: +- Keep the post specifically about beta testing, feedback, and product building. +- Provide enough detail for testers to judge fit; do not make a low-effort link drop. +- Disclose the maker relationship and do not repeat the same promotion. +- Use the no-link body unless a direct public beta URL is separately verified + and approved. +- Re-check the live About page immediately before a maintainer posts manually. + +Title: + +```text +Looking for setup feedback on CodePilot, an iPhone companion for Codex sessions on a Mac +``` + +Body: + +```text +I am the maker of CodePilot. I am looking for a small number of beta testers who already run Codex on macOS and can give specific setup feedback. + +CodePilot leaves the Codex session, credentials, and project files on the Mac. A token-protected local gateway gives the iPhone companion narrow access to session status, threads, file uploads, notifications, and controls to steer or stop an active turn. For the current public-beta remote path, the tester connects through a Cloudflare Tunnel they control. + +The beta questions I most need answered are: + +- Is the Mac gateway and iPhone pairing flow understandable without live help? +- Is it clear what the gateway token protects and how to recover from a rejected token? +- Does checking or steering a coding-agent turn from a phone solve a real workflow problem? +- Which trust or setup concern would make you stop testing? + +This is a feedback request, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, private logs, screenshots containing private project data, or project files in replies. +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. @@ -676,4 +717,6 @@ Escalation needed: yes/no An escalation note was created at `ops/agents/escalations/community-drafts.md`. Draft preparation is complete, but all public posting remains blocked on maintainer approval. The `r/TestFlight` -draft additionally requires a verified public TestFlight link. +draft additionally requires a verified public TestFlight link. The +`r/BetaTests` draft requires an approved identity that meets the community's +24-hour account-age and 2-karma eligibility gates. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index ddcc34f3..41ed22d0 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -8,6 +8,10 @@ Status: maintainer action required before any public posting. an account solely to satisfy this request. - For `r/TestFlight`, provide or approve a verified public Apple TestFlight link; the draft must contain no other link. +- For `r/BetaTests`, approve the no-link tester-feedback draft and confirm that + the `codepilotios` identity is at least 24 hours old with at least 2 combined + Reddit karma. The community currently permits product beta posts without + moderator pre-approval, but final maintainer approval is still required. - Do not cross-post the beta recruitment to `r/appledevelopers`, which directs TestFlight apps to `r/TestFlight`. Do not use the `r/iosapps` main feed for this AI-first beta; its monthly App Shelf requires approved pricing/status From e3d52a8a2143b9d032158c555ae2982e9fd5d58a Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 22:01:12 +0200 Subject: [PATCH 36/57] docs: correct iOSApps promotion eligibility --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 4 ++-- ops/agents/escalations/community-drafts.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index b8ceb27f..e3f89d9f 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -42,7 +42,7 @@ Operational rules for this queue: | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/BetaTests` | Medium | The community welcomes product beta-testing and feedback posts, but prohibits spam and low-effort submissions. Posting accounts must be at least 24 hours old and have at least 2 combined Reddit karma. The sidebar says product posts do not need moderator pre-approval. Sources: and | Useful secondary tester-recruitment target after the identity is approved and eligible. Use the specific no-link feedback draft first, disclose the maker relationship, and add only a separately approved public beta URL. Re-check the live rules immediately before posting. | | `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | -| `r/iosapps` | Low / hold for approved metadata | Requires 10 local karma for main-feed eligibility, official App Store or TestFlight distribution, infrequent promotion, disclosure, and either trust/transparency qualification or the monthly megathread. The July 2026 policy excludes AI-first apps from the main feed and routes generative-AI/AI-wrapper promotion to the App Shelf, whose required ABC format includes pricing and a direct App Store link. Sources: and | Do not use the main feed. Hold the megathread placement until a maintainer approves distribution eligibility, exact pricing/status language, and the direct store link; this run may not invent or imply those details. | +| `r/iosapps` | Not eligible | Requires 10 local karma, official App Store or TestFlight distribution, infrequent disclosed promotion, and either trust/transparency qualification or monthly-megathread use. More importantly, the current rules prohibit generative-AI and AI-wrapper apps and explicitly direct them to `r/GenAiApps`; the July 2026 policy likewise says AI-first apps do not belong in `r/iosapps`. Sources: and | Do not use either the main feed or the App Shelf for CodePilot promotion while this exclusion remains. Use `r/GenAiApps` as the rules-directed alternative after maintainer approval. | | `r/iPhoneApps` | Low / hold until genuine participation | The live rules allow recommending your own iPhone app only when it is relevant to an existing discussion, clearly disclosed, and useful. Shared apps must be available on the Apple App Store and include details such as description and cost. A 2026-07-16 moderator announcement removed the Developer flair and says pure promotional drops will be removed; developers should participate and contribute before mentioning their app in context. Sources: and | Do not prepare a standalone launch post or use a new promotion-only account. Revisit only after the approved identity has genuine community participation and CodePilot has an approved App Store listing and status language. Use modmail, not direct moderator contact, if eligibility remains unclear. | | `r/GenAiApps` | Medium-high | The community is specifically for generative-AI apps. Current rules allow self-promotion when it provides value and context, require an appropriate flair, prohibit spam/duplicate or low-effort posts, require context for links, and prohibit unsafe links or personal information. Source: | Good text-first beta-feedback candidate after maintainer approval. Use the `iOS` flair, disclose the maker relationship, explain the Codex-specific workflow, and include no link until the public beta URL is approved. Re-check recent posts and flair options immediately before posting. | | `r/AI_Agents` | Medium-high | The live rules permit limited self-promotion at roughly one in ten posts/comments, prohibit promotion-only accounts and low-context posts, require links to be placed in comments, and route project showcases to the weekly Project Display thread. The current weekly thread explicitly welcomes AI agents and LLM apps. Sources: and | Good agent-workflow feedback target only after the approved identity has genuine non-promotional participation. Use the weekly Project Display thread, disclose the maker relationship, and keep the first draft link-free unless a direct public beta or repository URL is separately approved. | @@ -68,7 +68,7 @@ Operational rules for this queue: - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/BetaTests`: no moderator pre-approval is currently required, but launch-plan maintainer approval still is. Confirm that `codepilotios` is at least 24 hours old and has at least 2 combined Reddit karma. Keep the post specific to beta testing, constructive, and non-spammy; use the no-link draft unless a public beta URL is separately approved. - `r/appledevelopers`: do not duplicate the beta-recruitment post; its rules direct TestFlight apps to `r/TestFlight`. Any later technical post must be substantive, limited, transparent, and maintainer-approved. -- `r/iosapps`: do not use the main feed for this AI-first beta. The monthly App Shelf is the apparent promotion route, but its ABC format requires pricing and a direct App Store link; hold until a maintainer approves those fields and confirms current distribution eligibility. +- `r/iosapps`: do not use either the main feed or the App Shelf. The current rules prohibit generative-AI and AI-wrapper apps and direct them to `r/GenAiApps`; re-check only if the rule changes. - `r/iPhoneApps`: do not make a standalone promotional post. The Developer flair was removed on 2026-07-16, self-mentions must arise from genuine participation and add value, and shared apps must be available on the Apple App Store with approved cost/status details. Use modmail rather than contacting moderators directly if a later contextual mention is uncertain. - `r/GenAiApps`: general rules allow valuable self-promotion, but a maintainer must still approve the final post. Use the `iOS` flair, provide useful context, avoid repeat or low-effort promotion, and include no link until the public beta URL is approved. - `r/AI_Agents`: use only the current weekly Project Display thread after the approved identity has genuine participation and its self-promotion ratio is safely below one in ten. Put any separately approved link in a reply rather than the project description, provide substantive workflow context, and have a maintainer post manually. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 41ed22d0..dcf1a7aa 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -13,9 +13,9 @@ Status: maintainer action required before any public posting. Reddit karma. The community currently permits product beta posts without moderator pre-approval, but final maintainer approval is still required. - Do not cross-post the beta recruitment to `r/appledevelopers`, which directs - TestFlight apps to `r/TestFlight`. Do not use the `r/iosapps` main feed for - this AI-first beta; its monthly App Shelf requires approved pricing/status - language and a direct App Store link before a compliant draft can be posted. + TestFlight apps to `r/TestFlight`. Do not use the `r/iosapps` main feed or + App Shelf: its current rules prohibit generative-AI and AI-wrapper apps and + direct them to `r/GenAiApps`. - `r/GenAiApps` is a text-first alternative whose rules allow contextual, valuable self-promotion. Approve the prepared copy and `iOS` flair before a human posts it; do not add a beta link until that URL is verified and approved. From 4c1c5ca81f5403d4a682e3d0f4625fd75fe91e46 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 22:52:49 +0200 Subject: [PATCH 37/57] docs: expand beta tester community drafts --- .../2026-07-01-codepilot-beta-drafts.md | 51 ++++++++++++++++++- ops/agents/escalations/community-drafts.md | 9 ++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index e3f89d9f..dc1e8a05 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -41,6 +41,8 @@ Operational rules for this queue: | `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/BetaTests` | Medium | The community welcomes product beta-testing and feedback posts, but prohibits spam and low-effort submissions. Posting accounts must be at least 24 hours old and have at least 2 combined Reddit karma. The sidebar says product posts do not need moderator pre-approval. Sources: and | Useful secondary tester-recruitment target after the identity is approved and eligible. Use the specific no-link feedback draft first, disclose the maker relationship, and add only a separately approved public beta URL. Re-check the live rules immediately before posting. | +| `r/alphaandbetausers` | Medium | The live About page currently lists no community-specific rules beyond Reddit-wide policy, and recent posts include explicit iOS/TestFlight tester requests. Source: and recent example: | Useful secondary tester-recruitment target. Use the link-free, feedback-specific draft after maintainer approval; disclose the maker relationship and do not offer reciprocal testing or incentives that have not been approved. Re-check the live About page and recent moderation behavior immediately before posting. | +| `r/BetaTestersNeeded` | Not eligible for this run | The community is restricted to approved contributors and requires a post flair, a tester perk in the title and body, and a signup or beta link. It permits repeat promotion only after seven days. Source: | Hold. Do not request contributor approval, invent a perk, imply paid-tier value, or post an unapproved link. Reconsider only if a maintainer separately approves the identity, a concrete non-pricing perk, and a verified public beta URL. | | `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | | `r/iosapps` | Not eligible | Requires 10 local karma, official App Store or TestFlight distribution, infrequent disclosed promotion, and either trust/transparency qualification or monthly-megathread use. More importantly, the current rules prohibit generative-AI and AI-wrapper apps and explicitly direct them to `r/GenAiApps`; the July 2026 policy likewise says AI-first apps do not belong in `r/iosapps`. Sources: and | Do not use either the main feed or the App Shelf for CodePilot promotion while this exclusion remains. Use `r/GenAiApps` as the rules-directed alternative after maintainer approval. | | `r/iPhoneApps` | Low / hold until genuine participation | The live rules allow recommending your own iPhone app only when it is relevant to an existing discussion, clearly disclosed, and useful. Shared apps must be available on the Apple App Store and include details such as description and cost. A 2026-07-16 moderator announcement removed the Developer flair and says pure promotional drops will be removed; developers should participate and contribute before mentioning their app in context. Sources: and | Do not prepare a standalone launch post or use a new promotion-only account. Revisit only after the approved identity has genuine community participation and CodePilot has an approved App Store listing and status language. Use modmail, not direct moderator contact, if eligibility remains unclear. | @@ -67,6 +69,8 @@ Operational rules for this queue: - `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/BetaTests`: no moderator pre-approval is currently required, but launch-plan maintainer approval still is. Confirm that `codepilotios` is at least 24 hours old and has at least 2 combined Reddit karma. Keep the post specific to beta testing, constructive, and non-spammy; use the no-link draft unless a public beta URL is separately approved. +- `r/alphaandbetausers`: no community-specific rules are currently shown, but launch-plan maintainer approval still applies. Use the link-free tester-feedback draft, disclose the maker relationship, avoid unapproved incentives or test-for-test promises, and re-check the live rules and recent removals before posting. +- `r/BetaTestersNeeded`: hold. Posting requires approved-contributor status, a tester perk in the title and body, and a signup/test link. Those gates require maintainer decisions and external account action that this run must not infer or perform. - `r/appledevelopers`: do not duplicate the beta-recruitment post; its rules direct TestFlight apps to `r/TestFlight`. Any later technical post must be substantive, limited, transparent, and maintainer-approved. - `r/iosapps`: do not use either the main feed or the App Shelf. The current rules prohibit generative-AI and AI-wrapper apps and direct them to `r/GenAiApps`; re-check only if the rule changes. - `r/iPhoneApps`: do not make a standalone promotional post. The Developer flair was removed on 2026-07-16, self-mentions must arise from genuine participation and add value, and shared apps must be available on the Apple App Store with approved cost/status details. Use modmail rather than contacting moderators directly if a later contextual mention is uncertain. @@ -649,6 +653,47 @@ The beta questions I most need answered are: This is a feedback request, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, private logs, screenshots containing private project data, or project files in replies. ``` +## Draft 16: `r/alphaandbetausers` Tester Feedback Post + +Posting target: `r/alphaandbetausers` main feed. + +Approval status: needs maintainer approval before posting. Use the link-free +body unless a direct public beta URL is separately verified and approved. + +Rule and community notes: +- The live About page currently lists no community-specific rules, so + Reddit-wide authenticity and spam rules still apply. +- Recent posts show that explicit iOS and TestFlight tester requests fit the + community, but this is observed practice rather than guaranteed approval. +- Disclose the maker relationship and ask for specific testing feedback. +- Do not promise reciprocal testing, rewards, or other incentives without + separate maintainer approval. +- Do not ask for upvotes or private project data, and do not repeat the post. +- Re-check the live About page and recent removals before a maintainer posts. + +Title: + +```text +Looking for Mac-based Codex users to test an iPhone companion setup +``` + +Body: + +```text +I am the maker of CodePilot. I am looking for a small number of beta testers who already run Codex on macOS and can give specific feedback on a Mac-to-iPhone setup. + +CodePilot keeps the Codex session, credentials, and project files on the Mac. A token-protected local gateway gives the iPhone companion narrow access to session status, threads, file uploads, notifications, and controls to steer or stop an active turn. For remote access in the current beta, testers use a Cloudflare Tunnel they control. + +The questions I most need answered are: + +- Can you complete the Mac gateway and iPhone connection flow without live help? +- Is it clear what the gateway token protects and how to recover from a rejected connection? +- Does checking or steering a coding-agent turn from a phone solve a real workflow problem? +- Which setup or trust concern would make you stop testing? + +This is a feedback request, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, private logs, screenshots containing private project data, or project files in replies. +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. @@ -719,4 +764,8 @@ An escalation note was created at all public posting remains blocked on maintainer approval. The `r/TestFlight` draft additionally requires a verified public TestFlight link. The `r/BetaTests` draft requires an approved identity that meets the community's -24-hour account-age and 2-karma eligibility gates. +24-hour account-age and 2-karma eligibility gates. The `r/alphaandbetausers` +draft has no visible community-specific eligibility threshold, but still needs +maintainer approval and a live-rule re-check. `r/BetaTestersNeeded` remains held +because its approved-contributor, mandatory-perk, and signup-link gates require +decisions and external account actions outside this run. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index dcf1a7aa..c80ab3fc 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -12,6 +12,15 @@ Status: maintainer action required before any public posting. the `codepilotios` identity is at least 24 hours old with at least 2 combined Reddit karma. The community currently permits product beta posts without moderator pre-approval, but final maintainer approval is still required. +- For `r/alphaandbetausers`, approve the prepared link-free tester-feedback + draft before a human posts it. The live About page currently shows no + community-specific rules, but the rules and recent removals must be checked + again immediately before posting. Do not add reciprocal-testing promises, + rewards, or an unapproved beta link. +- Hold `r/BetaTestersNeeded`. It is restricted to approved contributors and + requires a tester perk in the title and body plus a signup/test link. Do not + request approval, invent a perk, imply paid-tier value, or add an unverified + link for this placement. - Do not cross-post the beta recruitment to `r/appledevelopers`, which directs TestFlight apps to `r/TestFlight`. Do not use the `r/iosapps` main feed or App Shelf: its current rules prohibit generative-AI and AI-wrapper apps and From d0380b7860fdef3dd6f46a32d1df76cc923d2e4b Mon Sep 17 00:00:00 2001 From: CodePilot Date: Fri, 17 Jul 2026 23:37:08 +0200 Subject: [PATCH 38/57] docs: stage community beta rollout --- .../2026-07-01-codepilot-beta-drafts.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index dc1e8a05..929f7965 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -91,6 +91,31 @@ Operational rules for this queue: - Hacker News: a maintainer should write the actual comment manually because HN asks users not to post generated or AI-edited comments. - Product Hunt: hold until account, maker identity, screenshots, launch URL, and current beta/pricing status are approved. +## Recommended Approval Queue + +Use a staged rollout rather than sending substantially similar beta requests to +multiple communities at once. A maintainer should re-check the live target and +approve the exact copy immediately before every manual post or comment. + +1. **First placement:** use Draft 1 in the current `r/ChatGPTCoding` + self-promotion thread. It is the strongest no-link fit for the first target + audience and has a designated promotion route. Promote CodePilot there only + once. +2. **Text-first feedback:** if the first placement produces useful discussion, + consider Draft 11 in `r/GenAiApps`. Use `iOS` flair and keep it link-free + until the public beta URL is approved. +3. **Tester recruitment:** after confirming identity eligibility, choose one of + Draft 15 for `r/BetaTests` or Draft 16 for `r/alphaandbetausers`; do not post + both at the same time. Wait for feedback before considering the other. +4. **Direct installation:** once a verified public Apple TestFlight URL is + approved, use Draft 10 in `r/TestFlight` with `iOS` flair and only that link. + Put later beta updates in the original post's comments rather than reposting. +5. **Participation-gated communities:** consider `r/AI_Agents`, `r/codex`, + `r/iOSProgramming`, `r/swift`, and `r/MacOS` only after their activity, + timing, moderator, and maintainer gates are satisfied. Keep all other held + targets out of the launch queue unless their rules or CodePilot's release + status materially change. + ## Draft Reddit Profile Profile display name: From 07cc14735072b03d2b9bd9f3c0a1f896128f7eea Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 00:22:31 +0200 Subject: [PATCH 39/57] docs: refresh community beta placement --- .../2026-07-01-codepilot-beta-drafts.md | 46 ++++++++++--------- ops/agents/escalations/community-drafts.md | 8 +++- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 929f7965..f31723b2 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -2,10 +2,10 @@ Status: draft only. Do not post publicly without explicit maintainer approval. -Last rule review: 2026-07-17. +Last rule review: 2026-07-18. Review method: checked public Reddit rule/sidebar/search-visible pages, moderator -announcements, removal notices, and recent promotion threads on 2026-07-17. +announcements, removal notices, and recent promotion threads on 2026-07-18. Re-check each target immediately before any human-approved posting because subreddit rules and megathread formats change. @@ -38,8 +38,8 @@ Operational rules for this queue: | Community | Fit | Rule summary | Recommendation | | --- | --- | --- | --- | -| `r/ChatGPTCoding` | High | Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Best first Reddit placement once a current thread is confirmed. Use the self-promotion thread only unless moderators approve main-feed placement. Human approval before posting. | -| `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | +| `r/ChatGPTCoding` | High topical fit, currently gated | The community is currently restricted: anyone may view, but only approved users may contribute. Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Hold unless the approved `codepilotios` identity is already an approved contributor and a current self-promotion thread is confirmed. Do not request contributor access or sponsorship solely for this unattended run. Human approval remains required before posting. | +| `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/BetaTests` | Medium | The community welcomes product beta-testing and feedback posts, but prohibits spam and low-effort submissions. Posting accounts must be at least 24 hours old and have at least 2 combined Reddit karma. The sidebar says product posts do not need moderator pre-approval. Sources: and | Useful secondary tester-recruitment target after the identity is approved and eligible. Use the specific no-link feedback draft first, disclose the maker relationship, and add only a separately approved public beta URL. Re-check the live rules immediately before posting. | | `r/alphaandbetausers` | Medium | The live About page currently lists no community-specific rules beyond Reddit-wide policy, and recent posts include explicit iOS/TestFlight tester requests. Source: and recent example: | Useful secondary tester-recruitment target. Use the link-free, feedback-specific draft after maintainer approval; disclose the maker relationship and do not offer reciprocal testing or incentives that have not been approved. Re-check the live About page and recent moderation behavior immediately before posting. | | `r/BetaTestersNeeded` | Not eligible for this run | The community is restricted to approved contributors and requires a post flair, a tester perk in the title and body, and a signup or beta link. It permits repeat promotion only after seven days. Source: | Hold. Do not request contributor approval, invent a perk, imply paid-tier value, or post an unapproved link. Reconsider only if a maintainer separately approves the identity, a concrete non-pricing perk, and a verified public beta URL. | @@ -66,7 +66,7 @@ Operational rules for this queue: ## Rules That Require Approval Before Posting - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. -- `r/ChatGPTCoding`: use the self-promotion thread for this run; main-feed promotion needs modmail/sponsorship approval. +- `r/ChatGPTCoding`: currently restricted to approved contributors. Hold unless the approved `codepilotios` identity already has contributor access and a current self-promotion thread is confirmed. Main-feed promotion needs modmail/sponsorship approval; do not request access or sponsorship in this unattended run. - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/BetaTests`: no moderator pre-approval is currently required, but launch-plan maintainer approval still is. Confirm that `codepilotios` is at least 24 hours old and has at least 2 combined Reddit karma. Keep the post specific to beta testing, constructive, and non-spammy; use the no-link draft unless a public beta URL is separately approved. - `r/alphaandbetausers`: no community-specific rules are currently shown, but launch-plan maintainer approval still applies. Use the link-free tester-feedback draft, disclose the maker relationship, avoid unapproved incentives or test-for-test promises, and re-check the live rules and recent removals before posting. @@ -97,24 +97,22 @@ Use a staged rollout rather than sending substantially similar beta requests to multiple communities at once. A maintainer should re-check the live target and approve the exact copy immediately before every manual post or comment. -1. **First placement:** use Draft 1 in the current `r/ChatGPTCoding` - self-promotion thread. It is the strongest no-link fit for the first target - audience and has a designated promotion route. Promote CodePilot there only - once. -2. **Text-first feedback:** if the first placement produces useful discussion, - consider Draft 11 in `r/GenAiApps`. Use `iOS` flair and keep it link-free - until the public beta URL is approved. -3. **Tester recruitment:** after confirming identity eligibility, choose one of +1. **First placement:** use Draft 11 in `r/GenAiApps`. It is public, directly + relevant to generative-AI apps, and currently permits contextual, valuable + self-promotion. Use `iOS` flair, disclose the maker relationship, and keep it + link-free until the public beta URL is approved. +2. **Tester recruitment:** after the first placement produces useful discussion + and identity eligibility is confirmed, choose one of Draft 15 for `r/BetaTests` or Draft 16 for `r/alphaandbetausers`; do not post both at the same time. Wait for feedback before considering the other. -4. **Direct installation:** once a verified public Apple TestFlight URL is +3. **Direct installation:** once a verified public Apple TestFlight URL is approved, use Draft 10 in `r/TestFlight` with `iOS` flair and only that link. Put later beta updates in the original post's comments rather than reposting. -5. **Participation-gated communities:** consider `r/AI_Agents`, `r/codex`, - `r/iOSProgramming`, `r/swift`, and `r/MacOS` only after their activity, - timing, moderator, and maintainer gates are satisfied. Keep all other held - targets out of the launch queue unless their rules or CodePilot's release - status materially change. +4. **Restricted or participation-gated communities:** consider + `r/ChatGPTCoding`, `r/AI_Agents`, `r/codex`, `r/iOSProgramming`, `r/swift`, + and `r/MacOS` only after their contributor, activity, timing, moderator, and + maintainer gates are satisfied. Keep all other held targets out of the launch + queue unless their rules or CodePilot's release status materially change. ## Draft Reddit Profile @@ -144,10 +142,12 @@ This account is for transparent beta feedback only. I will disclose my relations Posting target: current or next `r/ChatGPTCoding` self-promotion thread. -Approval status: needs maintainer approval before posting. +Approval status: held unless `codepilotios` is already an approved contributor; +then needs maintainer approval before posting. Rule notes: - Fits the self-promotion thread. +- The community is currently restricted to approved contributors. - Use a comment in the thread, not a main-feed post, unless moderators explicitly approve otherwise. - Do not mention selling access to models. - Promote once per project. @@ -786,8 +786,10 @@ Escalation needed: yes/no An escalation note was created at `ops/agents/escalations/community-drafts.md`. Draft preparation is complete, but -all public posting remains blocked on maintainer approval. The `r/TestFlight` -draft additionally requires a verified public TestFlight link. The +all public posting remains blocked on maintainer approval. The recommended first +placement is now the link-free `r/GenAiApps` draft with `iOS` flair because +`r/ChatGPTCoding` is currently restricted to approved contributors. The +`r/TestFlight` draft additionally requires a verified public TestFlight link. The `r/BetaTests` draft requires an approved identity that meets the community's 24-hour account-age and 2-karma eligibility gates. The `r/alphaandbetausers` draft has no visible community-specific eligibility threshold, but still needs diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index c80ab3fc..09139eb5 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -2,10 +2,16 @@ Status: maintainer action required before any public posting. -- Review and approve the proposed first community placement and final copy. +- Review and approve the proposed first placement: the link-free `r/GenAiApps` + draft with `iOS` flair. The previously preferred `r/ChatGPTCoding` target is + currently restricted to approved contributors, so hold it unless + `codepilotios` already has contributor access. - Confirm that the `codepilotios` identity is approved and eligible under the target community's account-age, karma, and participation rules. Do not create an account solely to satisfy this request. +- Do not request `r/ChatGPTCoding` contributor access or sponsorship solely for + this launch run. If `codepilotios` already has access, re-check the current + self-promotion thread and approve the exact comment before a human posts it. - For `r/TestFlight`, provide or approve a verified public Apple TestFlight link; the draft must contain no other link. - For `r/BetaTests`, approve the no-link tester-feedback draft and confirm that From cba6871e4a2f8fe14a367e37ac58f3f5c9398f04 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 05:59:03 +0200 Subject: [PATCH 40/57] docs: refresh community promotion gates --- .../2026-07-01-codepilot-beta-drafts.md | 31 +++++++++++-------- ops/agents/escalations/community-drafts.md | 13 ++++---- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index f31723b2..1f9dc3b1 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -38,7 +38,7 @@ Operational rules for this queue: | Community | Fit | Rule summary | Recommendation | | --- | --- | --- | --- | -| `r/ChatGPTCoding` | High topical fit, currently gated | The community is currently restricted: anyone may view, but only approved users may contribute. Sidebar/wiki guidance routes service-style promotion to the designated self-promotion thread unless sponsorship is approved by modmail. The latest search-visible official thread reviewed permits project sharing, says one promotion per project, and prohibits selling model access. Sources: and | Hold unless the approved `codepilotios` identity is already an approved contributor and a current self-promotion thread is confirmed. Do not request contributor access or sponsorship solely for this unattended run. Human approval remains required before posting. | +| `r/ChatGPTCoding` | High topical fit, thread-gated | The community is public. Its rules route service-style promotion to the designated self-promotion thread unless a main-feed sponsorship is approved through modmail. The current official thread permits project sharing, says one promotion per project, and prohibits selling model access; visible removals also show account-karma filtering. Sources: and | Use only the current self-promotion thread after confirming that it is still open and that the approved `codepilotios` identity can comment. Do not request sponsorship solely for this unattended run. Human approval remains required before posting. | | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/BetaTests` | Medium | The community welcomes product beta-testing and feedback posts, but prohibits spam and low-effort submissions. Posting accounts must be at least 24 hours old and have at least 2 combined Reddit karma. The sidebar says product posts do not need moderator pre-approval. Sources: and | Useful secondary tester-recruitment target after the identity is approved and eligible. Use the specific no-link feedback draft first, disclose the maker relationship, and add only a separately approved public beta URL. Re-check the live rules immediately before posting. | | `r/alphaandbetausers` | Medium | The live About page currently lists no community-specific rules beyond Reddit-wide policy, and recent posts include explicit iOS/TestFlight tester requests. Source: and recent example: | Useful secondary tester-recruitment target. Use the link-free, feedback-specific draft after maintainer approval; disclose the maker relationship and do not offer reciprocal testing or incentives that have not been approved. Re-check the live About page and recent moderation behavior immediately before posting. | @@ -66,7 +66,7 @@ Operational rules for this queue: ## Rules That Require Approval Before Posting - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. -- `r/ChatGPTCoding`: currently restricted to approved contributors. Hold unless the approved `codepilotios` identity already has contributor access and a current self-promotion thread is confirmed. Main-feed promotion needs modmail/sponsorship approval; do not request access or sponsorship in this unattended run. +- `r/ChatGPTCoding`: the community is public, but maker-authored service promotion belongs only in the current self-promotion thread. Confirm that the thread is still open and that the approved `codepilotios` identity clears its account-karma filters. Main-feed promotion needs modmail/sponsorship approval; do not request sponsorship in this unattended run. - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/BetaTests`: no moderator pre-approval is currently required, but launch-plan maintainer approval still is. Confirm that `codepilotios` is at least 24 hours old and has at least 2 combined Reddit karma. Keep the post specific to beta testing, constructive, and non-spammy; use the no-link draft unless a public beta URL is separately approved. - `r/alphaandbetausers`: no community-specific rules are currently shown, but launch-plan maintainer approval still applies. Use the link-free tester-feedback draft, disclose the maker relationship, avoid unapproved incentives or test-for-test promises, and re-check the live rules and recent removals before posting. @@ -108,11 +108,12 @@ approve the exact copy immediately before every manual post or comment. 3. **Direct installation:** once a verified public Apple TestFlight URL is approved, use Draft 10 in `r/TestFlight` with `iOS` flair and only that link. Put later beta updates in the original post's comments rather than reposting. -4. **Restricted or participation-gated communities:** consider - `r/ChatGPTCoding`, `r/AI_Agents`, `r/codex`, `r/iOSProgramming`, `r/swift`, - and `r/MacOS` only after their contributor, activity, timing, moderator, and - maintainer gates are satisfied. Keep all other held targets out of the launch - queue unless their rules or CodePilot's release status materially change. +4. **Thread, moderator, or participation-gated communities:** consider the + current `r/ChatGPTCoding` self-promotion thread, `r/AI_Agents`, `r/codex`, + `r/iOSProgramming`, `r/swift`, and `r/MacOS` only after their thread, + activity, timing, moderator, account-filter, and maintainer gates are + satisfied. Keep all other held targets out of the launch queue unless their + rules or CodePilot's release status materially change. ## Draft Reddit Profile @@ -142,12 +143,14 @@ This account is for transparent beta feedback only. I will disclose my relations Posting target: current or next `r/ChatGPTCoding` self-promotion thread. -Approval status: held unless `codepilotios` is already an approved contributor; -then needs maintainer approval before posting. +Approval status: needs maintainer approval before posting. Confirm that the +current thread remains open and that `codepilotios` can comment without an +account-karma removal. Rule notes: - Fits the self-promotion thread. -- The community is currently restricted to approved contributors. +- The community is public, but visible thread removals show account-karma + filtering and possible manual review. - Use a comment in the thread, not a main-feed post, unless moderators explicitly approve otherwise. - Do not mention selling access to models. - Promote once per project. @@ -787,9 +790,11 @@ Escalation needed: yes/no An escalation note was created at `ops/agents/escalations/community-drafts.md`. Draft preparation is complete, but all public posting remains blocked on maintainer approval. The recommended first -placement is now the link-free `r/GenAiApps` draft with `iOS` flair because -`r/ChatGPTCoding` is currently restricted to approved contributors. The -`r/TestFlight` draft additionally requires a verified public TestFlight link. The +placement remains the link-free `r/GenAiApps` draft with `iOS` flair. The +`r/ChatGPTCoding` self-promotion-thread draft is also eligible for maintainer +review once the thread and `codepilotios` account-karma eligibility are checked; +main-feed promotion remains out of scope. The `r/TestFlight` draft additionally +requires a verified public TestFlight link. The `r/BetaTests` draft requires an approved identity that meets the community's 24-hour account-age and 2-karma eligibility gates. The `r/alphaandbetausers` draft has no visible community-specific eligibility threshold, but still needs diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 09139eb5..b2a6fa58 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -3,15 +3,16 @@ Status: maintainer action required before any public posting. - Review and approve the proposed first placement: the link-free `r/GenAiApps` - draft with `iOS` flair. The previously preferred `r/ChatGPTCoding` target is - currently restricted to approved contributors, so hold it unless - `codepilotios` already has contributor access. + draft with `iOS` flair. `r/ChatGPTCoding` is public again, but maker-authored + promotion must use its current self-promotion thread and remains subject to + account-karma filtering. - Confirm that the `codepilotios` identity is approved and eligible under the target community's account-age, karma, and participation rules. Do not create an account solely to satisfy this request. -- Do not request `r/ChatGPTCoding` contributor access or sponsorship solely for - this launch run. If `codepilotios` already has access, re-check the current - self-promotion thread and approve the exact comment before a human posts it. +- Do not request `r/ChatGPTCoding` sponsorship solely for this launch run. + Re-check that the current self-promotion thread remains open, confirm that + `codepilotios` can comment without an account-karma removal, and approve the + exact comment before a human posts it. - For `r/TestFlight`, provide or approve a verified public Apple TestFlight link; the draft must contain no other link. - For `r/BetaTests`, approve the no-link tester-feedback draft and confirm that From 88d870fb22c429f9ac95364d3f0ee7d59de9621f Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 07:40:31 +0200 Subject: [PATCH 41/57] docs: expand community beta research --- .../2026-07-01-codepilot-beta-drafts.md | 89 ++++++++++++++++++- ops/agents/escalations/community-drafts.md | 10 +++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 1f9dc3b1..873b8f78 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -57,6 +57,8 @@ Operational rules for this queue: | `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and the July 2026 App Pile megathread. Rule/sidebar snippets require 10 local karma, megathread promotion unless the account qualifies through trust/transparency, post approval, and PCP-style promotion. Recent megathreads require pricing and link fields. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | | `r/MacOS` | Medium | The March 2026 Developer Saturday policy permits one self-promotional post per user each Saturday from 00:00 through 23:59 UTC. It explicitly welcomes accessible GitHub repositories, which are scanned by GitHub-Guard, and requires affiliation disclosure, useful context, and more than a low-effort link drop. Source: | Candidate Saturday placement after maintainer approval. Use a transparent, context-rich beta-feedback post and link only to an approved, accessible, security-ready repository or distribution page. | +| `r/macosprogramming` | Medium for a technical post | Posts must concern macOS software development. Open-source apps may be shared, but closed-source, paid, or IAP apps require prior moderator permission. The rules also prohibit repetitive promotion, link spam, and low-effort autogenerated posts. CodePilot is source-available under a noncommercial license rather than OSI open source, so the open-source exception must not be assumed. Sources: and | Moderator-first only. Ask whether a technical, text-first discussion of the Mac menu bar app and local gateway is acceptable. A maintainer must review and send the note manually, then rewrite/review the discussion copy before posting. | +| `r/indiehackers` | Medium for product validation | The live rules allow one self-promotional product post for feedback and critique rather than advertising. The rules call the required flair `SHOW IH`, while the visible flair filter currently says `Self Promotion`; moderator guidance also emphasizes contribution before promotion and rejects fake Q&A, karma farming, and artificial engagement. Sources: and | Held secondary option. Confirm the current flair and that the approved identity has genuine participation before using the one-time post. Use the link-free validation draft, disclose the maker relationship, and do not mention revenue, pricing, or ask for votes. | | `r/selfhosted` | Low-medium later | Mobile apps are allowed only as companions to a self-hosted backend. Promoted apps must be production-ready and documented. Projects under 3 months old may be posted only in the current New Project Megathread; Wednesday has a separate exception for dashboards and tools that help self-hosters. Source: | Hold for now. CodePilot has a local gateway and user-owned tunnel, but this is not primarily a general self-hosted server product; ask moderators only after public docs and production readiness are stronger. | | `r/github` self-promotion megathread | Medium | The designated recurring self-promotion thread accepts tools, libraries, and side projects hosted on GitHub. Comments should include a short project description, a GitHub repository link, and optionally the stack, main features, or contribution context. Source: | Candidate only after the public repository URL is approved, accessible, and privacy-audited. Use the megathread rather than a standalone promotional post, disclose the maker relationship, and ask for repository/setup feedback rather than votes. | | `r/ClaudeAI` | Low | Project showcases must be built with Claude/Claude Code or specifically for Claude by the poster, be free to try, explain how Claude helped, use minimal promotional language, and come from an account with more than 50 karma. Source: | Do not post CodePilot promotion because this Codex-focused beta does not meet the stated Claude-project relevance gate. Only answer organically if directly relevant and approved by a maintainer. | @@ -85,6 +87,8 @@ Operational rules for this queue: - `r/macapps`: requires 10 local karma, trust/transparency qualification or monthly megathread use, and post approval. Current App Pile PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. - `r/MacOS`: Saturday-only from 00:00 through 23:59 UTC, one promotional post per user per week, explicit developer/affiliate disclosure, useful context, and no low-effort link drop. GitHub repositories are allowed but will be scanned, so any linked repository must be approved, accessible, and security-ready. +- `r/macosprogramming`: prior moderator permission is required because CodePilot's noncommercial source-available license is not OSI open source. Any approved post must stay focused on macOS development, be technically substantive, and be reviewed manually to avoid low-effort autogenerated copy. +- `r/indiehackers`: only one product self-promotion is allowed, and it must seek feedback/critique rather than advertise. Confirm whether the live required flair is `SHOW IH` or `Self Promotion`, and wait until the approved identity has genuine community participation. Do not make revenue claims or manufacture engagement. - `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; projects under 3 months old belong only in the current New Project Megathread, and any maker-authored mention still needs moderator and maintainer approval. - `r/github`: use only the designated self-promotion megathread and only after a maintainer approves an accessible, privacy-audited public repository URL. The comment must include that GitHub link, so a no-link placeholder must not be posted. - `r/ClaudeAI`: do not promote this Codex-focused beta because it does not meet the community's Claude-built/Claude-focused showcase gate; eligible showcases also require more than 50 account karma. @@ -110,7 +114,8 @@ approve the exact copy immediately before every manual post or comment. Put later beta updates in the original post's comments rather than reposting. 4. **Thread, moderator, or participation-gated communities:** consider the current `r/ChatGPTCoding` self-promotion thread, `r/AI_Agents`, `r/codex`, - `r/iOSProgramming`, `r/swift`, and `r/MacOS` only after their thread, + `r/iOSProgramming`, `r/swift`, `r/MacOS`, `r/macosprogramming`, and + `r/indiehackers` only after their thread, activity, timing, moderator, account-filter, and maintainer gates are satisfied. Keep all other held targets out of the launch queue unless their rules or CodePilot's release status materially change. @@ -722,6 +727,84 @@ The questions I most need answered are: This is a feedback request, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, private logs, screenshots containing private project data, or project files in replies. ``` +## Draft 17: `r/indiehackers` One-Time Product Validation Post + +Posting target: `r/indiehackers` main feed using the current designated +self-promotion flair. + +Approval status: needs maintainer approval before posting. Hold until the live +flair requirement is confirmed and the approved identity has genuine, +non-promotional participation in the community. + +Rule notes: +- The community permits one self-promotional product post for feedback and + critique, not advertising. +- The written rule says `SHOW IH`, while the live flair list currently shows + `Self Promotion`; confirm the correct option immediately before posting. +- Do not use fake Q&A framing, repeat the post, make revenue claims, or ask for + votes or artificial engagement. +- Keep the body link-free unless a public beta URL is separately verified and + approved. + +Title: + +```text +Validating CodePilot: a phone companion for coding agents running on a Mac +``` + +Body: + +```text +I am the maker of CodePilot. I am validating a beta Mac + iPhone companion for people who run Codex sessions on a Mac they control. + +The constraint behind it is that the coding-agent session, credentials, and project files stay on the Mac. CodePilot adds a Mac menu bar app, a token-protected local gateway, and an iPhone client with narrow controls for session status, threads, file uploads, notifications, steering, and stopping an active turn. Remote access in the current beta uses a Cloudflare Tunnel controlled by the tester. + +I am trying to separate a real away-from-desk workflow from a feature that only sounds convenient. I would value critique on three points: + +- When, if ever, would you check or steer a coding-agent turn from a phone? +- Does keeping the session on the Mac make the trust boundary clearer, or does the gateway and tunnel setup outweigh that benefit? +- What is the smallest useful beta: read-only status and notifications, or active steering and stop controls? + +This is product validation, not an upvote request. I have left out the beta link until public distribution is approved. Please do not share credentials, gateway tokens, private hostnames, logs, screenshots containing private project data, or project files in replies. +``` + +## Draft 18: `r/macosprogramming` Moderator-First Technical Discussion + +Posting target: `r/macosprogramming`, only after moderator permission. + +Approval status: moderator and maintainer approval required. CodePilot is +source-available under a noncommercial license, not OSI open source, so do not +use the community's open-source promotion exception. + +Moderator note draft: + +```text +Hi mods, I am the maker of CodePilot, a beta Mac menu bar app with a local gateway and an iPhone companion for people running Codex on macOS. + +I saw that non-open-source app promotion requires prior permission. CodePilot's repository is source-available under a noncommercial license, so I am not assuming the open-source exception applies. + +Would a text-first technical discussion be acceptable if it focuses on the macOS architecture: keeping credentials and project files on the Mac, exposing a token-protected loopback gateway, coordinating menu bar state with active coding-agent turns, and asking for feedback on the security and lifecycle design? I would disclose that I am the maker, omit pricing and promotional claims, include no link unless you approve one, and keep the discussion specific to macOS development. +``` + +Discussion body if moderators approve; a maintainer should review and rewrite it +manually before posting to ensure it reflects the current implementation and +adds concrete developer value: + +```text +I am the maker of CodePilot, and I would value macOS architecture feedback on a beta menu bar app and local gateway for controlling coding-agent sessions from an iPhone. + +The Mac remains the source of truth for the Codex session, credentials, account state, and project files. The menu bar app coordinates account and usage state, while a token-protected loopback HTTP service exposes a narrow API for thread status, file uploads, notifications, steering, and stopping an active turn. Optional remote access is through a Cloudflare Tunnel configured by the tester. + +The design questions I am working through are: + +- Which gateway lifecycle and failure states should the menu bar app surface directly? +- What macOS-native confirmation or permission boundary would you expect before allowing remote steering or stop controls? +- Is a loopback-only service plus user-owned tunnel a comprehensible trust boundary for a Mac utility, or should the first beta remain local-network only? +- Which logs and diagnostics are useful for setup recovery without exposing tokens, hostnames, or project data? + +I am looking for technical critique rather than installs or votes. I have omitted links while the beta distribution and public repository are still under approval. +``` + ## Hacker News / Show HN Outline Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. @@ -801,3 +884,7 @@ draft has no visible community-specific eligibility threshold, but still needs maintainer approval and a live-rule re-check. `r/BetaTestersNeeded` remains held because its approved-contributor, mandatory-perk, and signup-link gates require decisions and external account actions outside this run. +The new `r/indiehackers` draft is a held, one-time validation option pending +flair confirmation and genuine participation. The new `r/macosprogramming` +technical draft is moderator-first because CodePilot's source-available license +does not qualify for that community's open-source exception. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index b2a6fa58..be82f77e 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -42,6 +42,16 @@ Status: maintainer action required before any public posting. - Do not use `r/iPhoneApps` for a standalone launch post. Its Developer flair was removed on 2026-07-16, and maker mentions now require genuine participation, contextual value, an App Store listing, and approved status/cost details. +- Hold `r/indiehackers` until a maintainer confirms whether the required live + flair is `SHOW IH` or `Self Promotion` and the approved identity has genuine + non-promotional participation. Approve the one-time, link-free validation + draft before a human posts it; do not make revenue claims or manufacture + engagement. +- Treat `r/macosprogramming` as moderator-first. CodePilot is source-available + under a noncommercial license, not OSI open source, so its open-source posting + exception must not be assumed. A maintainer must approve and manually send the + prepared moderator note, then review/rewrite any technical discussion before + posting. - Hold `r/vibecoding`: its developer-tool approval flow requires a public X community post and moderator DM. That external account action is outside this agent's authority and should not be performed solely for promotion. From d8f01e008230386f883196abf64828e95ec1a1a1 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 11:32:48 +0200 Subject: [PATCH 42/57] docs: record additional community promotion gate --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 2 ++ ops/agents/escalations/community-drafts.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 873b8f78..ff7c251e 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -44,6 +44,7 @@ Operational rules for this queue: | `r/alphaandbetausers` | Medium | The live About page currently lists no community-specific rules beyond Reddit-wide policy, and recent posts include explicit iOS/TestFlight tester requests. Source: and recent example: | Useful secondary tester-recruitment target. Use the link-free, feedback-specific draft after maintainer approval; disclose the maker relationship and do not offer reciprocal testing or incentives that have not been approved. Re-check the live About page and recent moderation behavior immediately before posting. | | `r/BetaTestersNeeded` | Not eligible for this run | The community is restricted to approved contributors and requires a post flair, a tester perk in the title and body, and a signup or beta link. It permits repeat promotion only after seven days. Source: | Hold. Do not request contributor approval, invent a perk, imply paid-tier value, or post an unapproved link. Reconsider only if a maintainer separately approves the identity, a concrete non-pricing perk, and a verified public beta URL. | | `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | +| `r/ShowMeYourApps` | Not eligible | The community accepts mobile-app promotion but its live rules explicitly prohibit AI content. Source: | Do not promote CodePilot there while the AI-content ban remains. Do not try to reframe the Codex companion as a non-AI app to bypass the rule. | | `r/iosapps` | Not eligible | Requires 10 local karma, official App Store or TestFlight distribution, infrequent disclosed promotion, and either trust/transparency qualification or monthly-megathread use. More importantly, the current rules prohibit generative-AI and AI-wrapper apps and explicitly direct them to `r/GenAiApps`; the July 2026 policy likewise says AI-first apps do not belong in `r/iosapps`. Sources: and | Do not use either the main feed or the App Shelf for CodePilot promotion while this exclusion remains. Use `r/GenAiApps` as the rules-directed alternative after maintainer approval. | | `r/iPhoneApps` | Low / hold until genuine participation | The live rules allow recommending your own iPhone app only when it is relevant to an existing discussion, clearly disclosed, and useful. Shared apps must be available on the Apple App Store and include details such as description and cost. A 2026-07-16 moderator announcement removed the Developer flair and says pure promotional drops will be removed; developers should participate and contribute before mentioning their app in context. Sources: and | Do not prepare a standalone launch post or use a new promotion-only account. Revisit only after the approved identity has genuine community participation and CodePilot has an approved App Store listing and status language. Use modmail, not direct moderator contact, if eligibility remains unclear. | | `r/GenAiApps` | Medium-high | The community is specifically for generative-AI apps. Current rules allow self-promotion when it provides value and context, require an appropriate flair, prohibit spam/duplicate or low-effort posts, require context for links, and prohibit unsafe links or personal information. Source: | Good text-first beta-feedback candidate after maintainer approval. Use the `iOS` flair, disclose the maker relationship, explain the Codex-specific workflow, and include no link until the public beta URL is approved. Re-check recent posts and flair options immediately before posting. | @@ -74,6 +75,7 @@ Operational rules for this queue: - `r/alphaandbetausers`: no community-specific rules are currently shown, but launch-plan maintainer approval still applies. Use the link-free tester-feedback draft, disclose the maker relationship, avoid unapproved incentives or test-for-test promises, and re-check the live rules and recent removals before posting. - `r/BetaTestersNeeded`: hold. Posting requires approved-contributor status, a tester perk in the title and body, and a signup/test link. Those gates require maintainer decisions and external account action that this run must not infer or perform. - `r/appledevelopers`: do not duplicate the beta-recruitment post; its rules direct TestFlight apps to `r/TestFlight`. Any later technical post must be substantive, limited, transparent, and maintainer-approved. +- `r/ShowMeYourApps`: do not post CodePilot there while its explicit no-AI-content rule remains. No moderator request or alternate framing is warranted for this beta campaign. - `r/iosapps`: do not use either the main feed or the App Shelf. The current rules prohibit generative-AI and AI-wrapper apps and direct them to `r/GenAiApps`; re-check only if the rule changes. - `r/iPhoneApps`: do not make a standalone promotional post. The Developer flair was removed on 2026-07-16, self-mentions must arise from genuine participation and add value, and shared apps must be available on the Apple App Store with approved cost/status details. Use modmail rather than contacting moderators directly if a later contextual mention is uncertain. - `r/GenAiApps`: general rules allow valuable self-promotion, but a maintainer must still approve the final post. Use the `iOS` flair, provide useful context, avoid repeat or low-effort promotion, and include no link until the public beta URL is approved. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index be82f77e..b04b3b8e 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -32,6 +32,9 @@ Status: maintainer action required before any public posting. TestFlight apps to `r/TestFlight`. Do not use the `r/iosapps` main feed or App Shelf: its current rules prohibit generative-AI and AI-wrapper apps and direct them to `r/GenAiApps`. +- Do not use `r/ShowMeYourApps`: although it accepts mobile-app promotion, its + live rules explicitly prohibit AI content. Do not reframe CodePilot to bypass + that rule or contact moderators solely to seek an exception. - `r/GenAiApps` is a text-first alternative whose rules allow contextual, valuable self-promotion. Approve the prepared copy and `iOS` flair before a human posts it; do not add a beta link until that URL is verified and approved. From 0b1e65ce3b85dc19c8fd1b48b4decfc344f167c3 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 12:32:04 +0200 Subject: [PATCH 43/57] docs: correct ChatGPTCoding contribution gate --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 9 +++++---- ops/agents/escalations/community-drafts.md | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index ff7c251e..17b7db4a 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -38,7 +38,7 @@ Operational rules for this queue: | Community | Fit | Rule summary | Recommendation | | --- | --- | --- | --- | -| `r/ChatGPTCoding` | High topical fit, thread-gated | The community is public. Its rules route service-style promotion to the designated self-promotion thread unless a main-feed sponsorship is approved through modmail. The current official thread permits project sharing, says one promotion per project, and prohibits selling model access; visible removals also show account-karma filtering. Sources: and | Use only the current self-promotion thread after confirming that it is still open and that the approved `codepilotios` identity can comment. Do not request sponsorship solely for this unattended run. Human approval remains required before posting. | +| `r/ChatGPTCoding` | High topical fit, restricted and thread-gated | The community currently says only approved users can contribute. Its rules route service-style promotion to the designated self-promotion thread unless a main-feed sponsorship is approved through modmail. The current official thread remains visible and permits one promotion per project, but visible removals also show account-karma filtering. Sources: and | Hold unless a maintainer confirms that the approved `codepilotios` identity is already allowed to contribute. If eligible, use only the current self-promotion thread after confirming that it is still open. Do not request contributor status or sponsorship solely for this unattended run. Human approval remains required before posting. | | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | | `r/BetaTests` | Medium | The community welcomes product beta-testing and feedback posts, but prohibits spam and low-effort submissions. Posting accounts must be at least 24 hours old and have at least 2 combined Reddit karma. The sidebar says product posts do not need moderator pre-approval. Sources: and | Useful secondary tester-recruitment target after the identity is approved and eligible. Use the specific no-link feedback draft first, disclose the maker relationship, and add only a separately approved public beta URL. Re-check the live rules immediately before posting. | | `r/alphaandbetausers` | Medium | The live About page currently lists no community-specific rules beyond Reddit-wide policy, and recent posts include explicit iOS/TestFlight tester requests. Source: and recent example: | Useful secondary tester-recruitment target. Use the link-free, feedback-specific draft after maintainer approval; disclose the maker relationship and do not offer reciprocal testing or incentives that have not been approved. Re-check the live About page and recent moderation behavior immediately before posting. | @@ -69,7 +69,7 @@ Operational rules for this queue: ## Rules That Require Approval Before Posting - Any public Reddit post, comment, or account profile must be approved by a maintainer per the launch plan. -- `r/ChatGPTCoding`: the community is public, but maker-authored service promotion belongs only in the current self-promotion thread. Confirm that the thread is still open and that the approved `codepilotios` identity clears its account-karma filters. Main-feed promotion needs modmail/sponsorship approval; do not request sponsorship in this unattended run. +- `r/ChatGPTCoding`: the community is currently restricted to approved contributors, and maker-authored service promotion belongs only in the current self-promotion thread. Hold unless a maintainer confirms that the approved `codepilotios` identity can contribute, the thread is still open, and the identity clears its account-karma filters. Main-feed promotion needs modmail/sponsorship approval; do not request contributor status or sponsorship in this unattended run. - `r/TestFlight`: hold until there is a verified, approved public TestFlight link. Use only that link, select `iOS` flair, keep the title free of recruitment tags, and avoid repeat posts; later updates belong in the original post's comments. - `r/BetaTests`: no moderator pre-approval is currently required, but launch-plan maintainer approval still is. Confirm that `codepilotios` is at least 24 hours old and has at least 2 combined Reddit karma. Keep the post specific to beta testing, constructive, and non-spammy; use the no-link draft unless a public beta URL is separately approved. - `r/alphaandbetausers`: no community-specific rules are currently shown, but launch-plan maintainer approval still applies. Use the link-free tester-feedback draft, disclose the maker relationship, avoid unapproved incentives or test-for-test promises, and re-check the live rules and recent removals before posting. @@ -156,8 +156,9 @@ account-karma removal. Rule notes: - Fits the self-promotion thread. -- The community is public, but visible thread removals show account-karma - filtering and possible manual review. +- The community is currently restricted to approved contributors, and visible + thread removals also show account-karma filtering and possible manual review. +- Do not request contributor status solely for this unattended run. - Use a comment in the thread, not a main-feed post, unless moderators explicitly approve otherwise. - Do not mention selling access to models. - Promote once per project. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index b04b3b8e..93f27912 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -3,9 +3,10 @@ Status: maintainer action required before any public posting. - Review and approve the proposed first placement: the link-free `r/GenAiApps` - draft with `iOS` flair. `r/ChatGPTCoding` is public again, but maker-authored - promotion must use its current self-promotion thread and remains subject to - account-karma filtering. + draft with `iOS` flair. `r/ChatGPTCoding` is currently restricted to approved + contributors; if `codepilotios` is already eligible, maker-authored promotion + must use its current self-promotion thread and remains subject to account-karma + filtering. Do not request contributor status solely for this launch run. - Confirm that the `codepilotios` identity is approved and eligible under the target community's account-age, karma, and participation rules. Do not create an account solely to satisfy this request. From b03a5590f1f7935d65c2f9162cd99417716925e3 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 13:27:14 +0200 Subject: [PATCH 44/57] docs: clarify BetaTests posting guidance --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 6 +++++- ops/agents/escalations/community-drafts.md | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 17b7db4a..d3f5edc7 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -40,7 +40,7 @@ Operational rules for this queue: | --- | --- | --- | --- | | `r/ChatGPTCoding` | High topical fit, restricted and thread-gated | The community currently says only approved users can contribute. Its rules route service-style promotion to the designated self-promotion thread unless a main-feed sponsorship is approved through modmail. The current official thread remains visible and permits one promotion per project, but visible removals also show account-karma filtering. Sources: and | Hold unless a maintainer confirms that the approved `codepilotios` identity is already allowed to contribute. If eligible, use only the current self-promotion thread after confirming that it is still open. Do not request contributor status or sponsorship solely for this unattended run. Human approval remains required before posting. | | `r/TestFlight` | High once the public beta link is live | Live rules require posts to concern a TestFlight app, allow only TestFlight links, require an app-name title without tags such as `[Tester]` or `[Recruiting]`, require platform flair, and ask developers not to flood the feed with repeat posts. Source: | Best direct tester-recruitment target after the public TestFlight link is verified and approved. Use `iOS` flair, keep the title to the app name plus a short message, and put later updates in comments rather than reposting. | -| `r/BetaTests` | Medium | The community welcomes product beta-testing and feedback posts, but prohibits spam and low-effort submissions. Posting accounts must be at least 24 hours old and have at least 2 combined Reddit karma. The sidebar says product posts do not need moderator pre-approval. Sources: and | Useful secondary tester-recruitment target after the identity is approved and eligible. Use the specific no-link feedback draft first, disclose the maker relationship, and add only a separately approved public beta URL. Re-check the live rules immediately before posting. | +| `r/BetaTests` | Medium | The community welcomes product beta-testing and feedback posts, but prohibits spam and low-effort submissions. Posting accounts must be at least 24 hours old and have at least 2 combined Reddit karma. The sidebar says product posts do not need moderator pre-approval. Current automated posting guidance asks authors to explain the product clearly and ask specific feedback questions; it recommends adding a link when possible but does not make one mandatory. Sources: , , and | Useful secondary tester-recruitment target after the identity is approved and eligible. Use the specific no-link feedback draft first, disclose the maker relationship, and add only a separately approved public beta URL. Re-check the live rules immediately before posting. | | `r/alphaandbetausers` | Medium | The live About page currently lists no community-specific rules beyond Reddit-wide policy, and recent posts include explicit iOS/TestFlight tester requests. Source: and recent example: | Useful secondary tester-recruitment target. Use the link-free, feedback-specific draft after maintainer approval; disclose the maker relationship and do not offer reciprocal testing or incentives that have not been approved. Re-check the live About page and recent moderation behavior immediately before posting. | | `r/BetaTestersNeeded` | Not eligible for this run | The community is restricted to approved contributors and requires a post flair, a tester perk in the title and body, and a signup or beta link. It permits repeat promotion only after seven days. Source: | Hold. Do not request contributor approval, invent a perk, imply paid-tier value, or post an unapproved link. Reconsider only if a maintainer separately approves the identity, a concrete non-pricing perk, and a verified public beta URL. | | `r/appledevelopers` | Medium for technical discussion, low for beta recruitment | Posts must concern Apple-platform development. Promotional content must be limited and disclose the developer relationship; the sidebar explicitly directs TestFlight apps to `r/TestFlight`. Source: | Do not cross-post the tester request here. Use `r/TestFlight` for recruitment. Consider this community later only for a substantive Apple-development discussion with maintainer approval and explicit maker disclosure. | @@ -661,6 +661,10 @@ create an account or manufacture activity to meet those thresholds. Rule notes: - Keep the post specifically about beta testing, feedback, and product building. - Provide enough detail for testers to judge fit; do not make a low-effort link drop. +- Follow the current posting guidance by explaining the product clearly and + asking specific feedback questions. A link is recommended when available, + but this draft must remain link-free until a direct public beta URL is + verified and approved. - Disclose the maker relationship and do not repeat the same promotion. - Use the no-link body unless a direct public beta URL is separately verified and approved. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 93f27912..9a2c6717 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -19,7 +19,10 @@ Status: maintainer action required before any public posting. - For `r/BetaTests`, approve the no-link tester-feedback draft and confirm that the `codepilotios` identity is at least 24 hours old with at least 2 combined Reddit karma. The community currently permits product beta posts without - moderator pre-approval, but final maintainer approval is still required. + moderator pre-approval, but final maintainer approval is still required. Its + current posting guidance asks for a clear product explanation and specific + feedback questions; the prepared draft includes both. Add a link only after a + direct public beta URL is verified and approved. - For `r/alphaandbetausers`, approve the prepared link-free tester-feedback draft before a human posts it. The live About page currently shows no community-specific rules, but the rules and recent removals must be checked From 326922764f826b8bf142aa462d2624604b450789 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 14:12:48 +0200 Subject: [PATCH 45/57] docs: record current Show HN contribution gate --- .../2026-07-01-codepilot-beta-drafts.md | 17 +++++++++++++---- ops/agents/escalations/community-drafts.md | 5 +++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index d3f5edc7..ac35aca0 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -63,7 +63,7 @@ Operational rules for this queue: | `r/selfhosted` | Low-medium later | Mobile apps are allowed only as companions to a self-hosted backend. Promoted apps must be production-ready and documented. Projects under 3 months old may be posted only in the current New Project Megathread; Wednesday has a separate exception for dashboards and tools that help self-hosters. Source: | Hold for now. CodePilot has a local gateway and user-owned tunnel, but this is not primarily a general self-hosted server product; ask moderators only after public docs and production readiness are stronger. | | `r/github` self-promotion megathread | Medium | The designated recurring self-promotion thread accepts tools, libraries, and side projects hosted on GitHub. Comments should include a short project description, a GitHub repository link, and optionally the stack, main features, or contribution context. Source: | Candidate only after the public repository URL is approved, accessible, and privacy-audited. Use the megathread rather than a standalone promotional post, disclose the maker relationship, and ask for repository/setup feedback rather than votes. | | `r/ClaudeAI` | Low | Project showcases must be built with Claude/Claude Code or specifically for Claude by the poster, be free to try, explain how Claude helped, use minimal promotional language, and come from an account with more than 50 karma. Source: | Do not post CodePilot promotion because this Codex-focused beta does not meet the stated Claude-project relevance gate. Only answer organically if directly relevant and approved by a maintainer. | -| Hacker News / Show HN | Medium | Show HN is for something people can try; HN asks submitters not to use it primarily for promotion and not to post generated or AI-edited comments. Sources: and | Human rewrite required. Prepare outline only; do not paste AI-generated text. | +| Hacker News / Show HN | Medium, currently contributor-gated | Show HN is for something people can try; HN asks submitters not to use it primarily for promotion and not to post generated or AI-edited comments. HN is also temporarily restricting Show HN submissions and asks prospective submitters to become genuine contributors before posting an occasional project. Sources: , , and | Hold unless a maintainer confirms that an existing approved public identity is eligible. Do not create an HN account or manufacture participation for this launch. If eligible later, a maintainer must rewrite the outline manually and post only when the beta is directly usable. | | Product Hunt | Medium later | Product Hunt recommends clear product pages, maker first comment, feedback requests rather than upvote requests, and warns against spamming communities where you have not been active. Sources: and | Not a first beta move. Requires account, launch assets, and pricing/status decisions, so hold for human approval. | ## Rules That Require Approval Before Posting @@ -94,7 +94,7 @@ Operational rules for this queue: - `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; projects under 3 months old belong only in the current New Project Megathread, and any maker-authored mention still needs moderator and maintainer approval. - `r/github`: use only the designated self-promotion megathread and only after a maintainer approves an accessible, privacy-audited public repository URL. The comment must include that GitHub link, so a no-link placeholder must not be posted. - `r/ClaudeAI`: do not promote this Codex-focused beta because it does not meet the community's Claude-built/Claude-focused showcase gate; eligible showcases also require more than 50 account karma. -- Hacker News: a maintainer should write the actual comment manually because HN asks users not to post generated or AI-edited comments. +- Hacker News: Show HN is temporarily restricted to people who have first become genuine contributors. Do not create an account or manufacture participation for this launch. If an existing approved public identity is eligible, a maintainer should still write the actual submission and comments manually because HN asks users not to post generated or AI-edited comments. - Product Hunt: hold until account, maker identity, screenshots, launch URL, and current beta/pricing status are approved. ## Recommended Approval Queue @@ -814,7 +814,14 @@ I am looking for technical critique rather than installs or votes. I have omitte ## Hacker News / Show HN Outline -Do not paste this as-is. HN guidelines ask users not to post generated or AI-edited comments. A maintainer should rewrite manually if posting. +Approval status: hold. HN is temporarily restricting Show HN submissions to +people who have first become genuine contributors. Do not create an account or +manufacture activity for this launch. A maintainer may use this outline only if +an existing approved public identity is eligible and the beta is directly +usable. + +Do not paste this as-is. HN guidelines ask users not to post generated or +AI-edited comments. A maintainer should rewrite manually if posting. Possible title: @@ -894,4 +901,6 @@ decisions and external account actions outside this run. The new `r/indiehackers` draft is a held, one-time validation option pending flair confirmation and genuine participation. The new `r/macosprogramming` technical draft is moderator-first because CodePilot's source-available license -does not qualify for that community's open-source exception. +does not qualify for that community's open-source exception. Hacker News / Show +HN is also held while its temporary genuine-contributor restriction remains; +this run did not create an account or manufacture participation. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 9a2c6717..b9c0aa85 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -54,6 +54,11 @@ Status: maintainer action required before any public posting. non-promotional participation. Approve the one-time, link-free validation draft before a human posts it; do not make revenue claims or manufacture engagement. +- Hold Hacker News / Show HN. HN is temporarily restricting Show HN submissions + to genuine contributors. Do not create an account or manufacture activity for + this launch. Reconsider only if an existing approved public identity is + eligible, the beta is directly usable, and a maintainer manually rewrites the + prepared outline. - Treat `r/macosprogramming` as moderator-first. CodePilot is source-available under a noncommercial license, not OSI open source, so its open-source posting exception must not be assumed. A maintainer must approve and manually send the From aedb9dd8f533d908cd8c84c3884f9f19e61c2764 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 16:11:03 +0200 Subject: [PATCH 46/57] docs: exclude iOSBeta from beta outreach --- .../community-drafts/2026-07-01-codepilot-beta-drafts.md | 2 ++ ops/agents/escalations/community-drafts.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index ac35aca0..18c3e133 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -47,6 +47,7 @@ Operational rules for this queue: | `r/ShowMeYourApps` | Not eligible | The community accepts mobile-app promotion but its live rules explicitly prohibit AI content. Source: | Do not promote CodePilot there while the AI-content ban remains. Do not try to reframe the Codex companion as a non-AI app to bypass the rule. | | `r/iosapps` | Not eligible | Requires 10 local karma, official App Store or TestFlight distribution, infrequent disclosed promotion, and either trust/transparency qualification or monthly-megathread use. More importantly, the current rules prohibit generative-AI and AI-wrapper apps and explicitly direct them to `r/GenAiApps`; the July 2026 policy likewise says AI-first apps do not belong in `r/iosapps`. Sources: and | Do not use either the main feed or the App Shelf for CodePilot promotion while this exclusion remains. Use `r/GenAiApps` as the rules-directed alternative after maintainer approval. | | `r/iPhoneApps` | Low / hold until genuine participation | The live rules allow recommending your own iPhone app only when it is relevant to an existing discussion, clearly disclosed, and useful. Shared apps must be available on the Apple App Store and include details such as description and cost. A 2026-07-16 moderator announcement removed the Developer flair and says pure promotional drops will be removed; developers should participate and contribute before mentioning their app in context. Sources: and | Do not prepare a standalone launch post or use a new promotion-only account. Revisit only after the approved identity has genuine community participation and CodePilot has an approved App Store listing and status language. Use modmail, not direct moderator contact, if eligibility remains unclear. | +| `r/iOSBeta` | Not eligible | Current automated removals state that posts about third-party apps in beta or TestFlight are not allowed; the community is for iOS beta discussion rather than recruiting testers for individual apps. Recent examples: and | Do not post CodePilot or a TestFlight recruitment request there. Use `r/TestFlight` after the public beta link is verified and approved. | | `r/GenAiApps` | Medium-high | The community is specifically for generative-AI apps. Current rules allow self-promotion when it provides value and context, require an appropriate flair, prohibit spam/duplicate or low-effort posts, require context for links, and prohibit unsafe links or personal information. Source: | Good text-first beta-feedback candidate after maintainer approval. Use the `iOS` flair, disclose the maker relationship, explain the Codex-specific workflow, and include no link until the public beta URL is approved. Re-check recent posts and flair options immediately before posting. | | `r/AI_Agents` | Medium-high | The live rules permit limited self-promotion at roughly one in ten posts/comments, prohibit promotion-only accounts and low-context posts, require links to be placed in comments, and route project showcases to the weekly Project Display thread. The current weekly thread explicitly welcomes AI agents and LLM apps. Sources: and | Good agent-workflow feedback target only after the approved identity has genuine non-promotional participation. Use the weekly Project Display thread, disclose the maker relationship, and keep the first draft link-free unless a direct public beta or repository URL is separately approved. | | `r/vibecoding` | Low / externally gated | CodePilot counts as a developer tool, and the live rules require developer-tool makers to obtain approval by joining the community on X, posting the tool there, and waiting for a moderator DM before making one introduction post. The subreddit also rejects fully generated low-effort content and requires educational detail for project posts. Sources: and | Hold. This run may not create or mutate accounts or post on non-GitHub external systems, so it cannot complete the X approval gate. Do not disguise CodePilot promotion as general discussion or a project breakdown. | @@ -78,6 +79,7 @@ Operational rules for this queue: - `r/ShowMeYourApps`: do not post CodePilot there while its explicit no-AI-content rule remains. No moderator request or alternate framing is warranted for this beta campaign. - `r/iosapps`: do not use either the main feed or the App Shelf. The current rules prohibit generative-AI and AI-wrapper apps and direct them to `r/GenAiApps`; re-check only if the rule changes. - `r/iPhoneApps`: do not make a standalone promotional post. The Developer flair was removed on 2026-07-16, self-mentions must arise from genuine participation and add value, and shared apps must be available on the Apple App Store with approved cost/status details. Use modmail rather than contacting moderators directly if a later contextual mention is uncertain. +- `r/iOSBeta`: do not post third-party beta or TestFlight recruitment. Current automated removals explicitly reserve the community for iOS beta discussion; use `r/TestFlight` instead after link and maintainer approval. - `r/GenAiApps`: general rules allow valuable self-promotion, but a maintainer must still approve the final post. Use the `iOS` flair, provide useful context, avoid repeat or low-effort promotion, and include no link until the public beta URL is approved. - `r/AI_Agents`: use only the current weekly Project Display thread after the approved identity has genuine participation and its self-promotion ratio is safely below one in ten. Put any separately approved link in a reply rather than the project description, provide substantive workflow context, and have a maintainer post manually. - `r/vibecoding`: do not post or prepare a disguised discussion placement. Developer-tool promotion requires prior approval through an X community post and moderator DM; satisfying that gate requires a maintainer-controlled external account action that this agent is not authorized to perform. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index b9c0aa85..348e489a 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -36,6 +36,9 @@ Status: maintainer action required before any public posting. TestFlight apps to `r/TestFlight`. Do not use the `r/iosapps` main feed or App Shelf: its current rules prohibit generative-AI and AI-wrapper apps and direct them to `r/GenAiApps`. +- Do not use `r/iOSBeta` for CodePilot recruitment. Current automated removals + prohibit posts about third-party apps in beta or TestFlight; use + `r/TestFlight` only after its link and maintainer approval gates are met. - Do not use `r/ShowMeYourApps`: although it accepts mobile-app promotion, its live rules explicitly prohibit AI content. Do not reframe CodePilot to bypass that rule or contact moderators solely to seek an exception. From c35aa2c761f4d7f99c12b11cadc3dffa64be3cf1 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 17:06:43 +0200 Subject: [PATCH 47/57] docs: add LLMDevs beta outreach draft --- .../2026-07-01-codepilot-beta-drafts.md | 57 ++++++++++++++++++- ops/agents/escalations/community-drafts.md | 5 ++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 18c3e133..13a16972 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -52,6 +52,7 @@ Operational rules for this queue: | `r/AI_Agents` | Medium-high | The live rules permit limited self-promotion at roughly one in ten posts/comments, prohibit promotion-only accounts and low-context posts, require links to be placed in comments, and route project showcases to the weekly Project Display thread. The current weekly thread explicitly welcomes AI agents and LLM apps. Sources: and | Good agent-workflow feedback target only after the approved identity has genuine non-promotional participation. Use the weekly Project Display thread, disclose the maker relationship, and keep the first draft link-free unless a direct public beta or repository URL is separately approved. | | `r/vibecoding` | Low / externally gated | CodePilot counts as a developer tool, and the live rules require developer-tool makers to obtain approval by joining the community on X, posting the tool there, and waiting for a moderator DM before making one introduction post. The subreddit also rejects fully generated low-effort content and requires educational detail for project posts. Sources: and | Hold. This run may not create or mutate accounts or post on non-GitHub external systems, so it cannot complete the X approval gate. Do not disguise CodePilot promotion as general discussion or a project breakdown. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | +| `r/LLMDevs` | High for a technical project discussion | The community prioritizes substantive LLM/NLP material. Its current self-promotion clarification allows projects under noncommercial licenses without prior moderator approval, prohibits commercial promotion, and bans disguised advertising or fake discussion framing. Sources: and | Good technical candidate because CodePilot is source-available under the PolyForm Noncommercial License, but describe that license exactly rather than calling the project open source. Hold until a maintainer approves an accessible, privacy-audited repository URL and confirms that the live rule still treats this specific license as eligible. Use the transparent architecture-and-trust draft, not a generic tester request. | | `r/SideProject` | Medium-high | The live About page currently shows no community-specific rules beyond Reddit-wide rules and says anyone may post. Project showcases and beta-feedback requests are common, and a current self-promotion thread explicitly invites projects and feedback. A recent high-engagement community discussion objects to promotion-only accounts and link drops. Sources: , , , and | Prefer the current self-promotion thread over a standalone post. Use the no-link, feedback-specific comment draft after maintainer approval, and re-check the thread and live About page immediately before posting. | | `r/iOSProgramming` | Medium | App posts are Saturday-only and limited to one post per app per year. The live App Saturday guide requires `Tech Stack Used`, `Development Challenge + How You Solved It`, and `AI Disclosure` content. The live rule also asks authors not to post without previous community activity but does not state a numeric karma threshold. Sources: and | Approval required. Hold until Saturday and do not use a new/inactive `codepilotios` account. | | `r/swift` | Medium | Self-promotion must be under 5% of the account's posts and comments in the subreddit. It is not allowed with fewer than 5 posts/comments in the sub or when the account is under 2 months old. Source: | Approval required. Do not post from a new `codepilotios` account. Use only after the account age, activity, and contribution-ratio thresholds are met. | @@ -84,6 +85,7 @@ Operational rules for this queue: - `r/AI_Agents`: use only the current weekly Project Display thread after the approved identity has genuine participation and its self-promotion ratio is safely below one in ten. Put any separately approved link in a reply rather than the project description, provide substantive workflow context, and have a maintainer post manually. - `r/vibecoding`: do not post or prepare a disguised discussion placement. Developer-tool promotion requires prior approval through an X community post and moderator DM; satisfying that gate requires a maintainer-controlled external account action that this agent is not authorized to perform. - `r/codex`: directly relevant to Codex, but ask moderators before a maker-authored beta-feedback post with any link; new-account karma filters may apply, and the explicit no-bots rule means a maintainer must post manually. +- `r/LLMDevs`: the published clarification allows noncommercial-licensed projects without prior moderator approval, but CodePilot must be described accurately as source-available under the PolyForm Noncommercial License rather than open source. A maintainer must approve the exact copy and an accessible, privacy-audited repository URL, confirm the live rule still covers that license, and post manually. Do not disguise the maker relationship or frame an advertisement as an organic question. - `r/SideProject`: the live About page currently lists no community-specific rules, but community sentiment strongly disfavors promotion-only participation. Prefer the current self-promotion thread, post manually only after maintainer approval, disclose the maker relationship, and use the no-link feedback draft rather than a standalone launch post. - `r/iOSProgramming`: Saturday-only app post, one app post per year, prior activity expected, and mandatory App Saturday content sections. The live rules do not currently state a numeric karma threshold, but eligibility must still be re-checked before posting. - `r/swift`: do not self-promote from an account under 2 months old or with fewer than 5 posts/comments in the subreddit; self-promotional content must also remain below 5% of the account's posts and comments there. @@ -116,7 +118,11 @@ approve the exact copy immediately before every manual post or comment. 3. **Direct installation:** once a verified public Apple TestFlight URL is approved, use Draft 10 in `r/TestFlight` with `iOS` flair and only that link. Put later beta updates in the original post's comments rather than reposting. -4. **Thread, moderator, or participation-gated communities:** consider the +4. **Technical follow-up:** after the repository URL is approved and + privacy-audited, consider Draft 19 in `r/LLMDevs`. Confirm that the live + noncommercial-license exception still covers CodePilot's exact license and + keep the post candid, technical, and creator-disclosed. +5. **Thread, moderator, or participation-gated communities:** consider the current `r/ChatGPTCoding` self-promotion thread, `r/AI_Agents`, `r/codex`, `r/iOSProgramming`, `r/swift`, `r/MacOS`, `r/macosprogramming`, and `r/indiehackers` only after their thread, @@ -814,6 +820,52 @@ The design questions I am working through are: I am looking for technical critique rather than installs or votes. I have omitted links while the beta distribution and public repository are still under approval. ``` +## Draft 19: `r/LLMDevs` Technical Project Discussion + +Posting target: `r/LLMDevs` main feed. + +Approval status: needs maintainer approval before posting. Hold until an +accessible public repository URL has passed the privacy audit and a maintainer +confirms that the current noncommercial-license exception covers the PolyForm +Noncommercial License. + +Rule notes: +- Disclose the maker relationship in the opening sentence. +- Describe CodePilot as source-available under a noncommercial license; do not + call it open source. +- Include concrete LLM-agent architecture and trust questions so the post adds + technical value. +- Do not use fake question framing, obscure the affiliation, make commercial + claims, or ask for installs, votes, or private messages. +- Replace the repository placeholder only with a separately approved, + accessible, privacy-audited public URL. +- Re-check the live About page and self-promotion clarification immediately + before a maintainer posts manually. + +Title: + +```text +Architecture feedback: keeping Codex sessions on a Mac while exposing narrow iPhone controls +``` + +Body: + +```text +I am the maker of CodePilot, a beta Mac menu bar app, local gateway, and iPhone companion for Codex users. The code is source-available under the PolyForm Noncommercial License; it is not OSI open source. + +The design keeps the Codex session, provider credentials, and project files on a Mac controlled by the user. A token-protected loopback gateway exposes a narrow API for session status, threads, file uploads, notifications, and controls to steer or stop an active turn. For the current public-beta remote path, the tester connects through a Cloudflare Tunnel they control. + +I would value technical criticism of three boundaries: + +- Is a loopback gateway plus a user-owned tunnel a clear trust model for remote agent controls, or is it too much setup for the benefit? +- Which actions should require fresh confirmation, stronger authentication, or remain read-only? +- What audit events and recovery information would help diagnose failures without leaking prompts, tokens, hostnames, or project data? + +Repository: [APPROVED PUBLIC REPOSITORY URL] + +I am looking for architecture and security feedback, not votes or promotional amplification. Please do not share credentials, gateway tokens, private hostnames, logs, or project files in replies. +``` + ## Hacker News / Show HN Outline Approval status: hold. HN is temporarily restricting Show HN submissions to @@ -906,3 +958,6 @@ technical draft is moderator-first because CodePilot's source-available license does not qualify for that community's open-source exception. Hacker News / Show HN is also held while its temporary genuine-contributor restriction remains; this run did not create an account or manufacture participation. +The new `r/LLMDevs` technical draft is held until a maintainer confirms that +the live noncommercial-license exception covers the PolyForm Noncommercial +License and approves an accessible, privacy-audited repository URL. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 348e489a..ecb1da99 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -73,6 +73,11 @@ Status: maintainer action required before any public posting. - For `r/github`, approve an accessible, privacy-audited public repository URL before using the prepared self-promotion megathread comment. Do not post the placeholder or use a standalone feed post. +- For `r/LLMDevs`, confirm that its live noncommercial-license exception covers + the PolyForm Noncommercial License, then approve the exact technical draft + and an accessible, privacy-audited public repository URL. Describe CodePilot + as source-available, not open source, and do not use disguised discussion + framing. - For `r/SideProject`, approve the prepared no-link comment and have a human use the current self-promotion thread. Do not use a standalone launch post or a promotion-only account; re-check the live About page and thread first. From be5677cbf8301a6e3502bdb1a6177036245d8751 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 17:50:44 +0200 Subject: [PATCH 48/57] docs: add Apple community beta draft --- .../2026-07-01-codepilot-beta-drafts.md | 52 ++++++++++++++++++- ops/agents/escalations/community-drafts.md | 6 +++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 13a16972..b22ca201 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -60,6 +60,7 @@ Operational rules for this queue: | `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and the July 2026 App Pile megathread. Rule/sidebar snippets require 10 local karma, megathread promotion unless the account qualifies through trust/transparency, post approval, and PCP-style promotion. Recent megathreads require pricing and link fields. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | | `r/MacOS` | Medium | The March 2026 Developer Saturday policy permits one self-promotional post per user each Saturday from 00:00 through 23:59 UTC. It explicitly welcomes accessible GitHub repositories, which are scanned by GitHub-Guard, and requires affiliation disclosure, useful context, and more than a low-effort link drop. Source: | Candidate Saturday placement after maintainer approval. Use a transparent, context-rich beta-feedback post and link only to an approved, accessible, security-ready repository or distribution page. | +| `r/apple` | Medium reach, low immediate eligibility | Developer app self-promotion is allowed only on Sundays in California time and must be a substantive self-post with a link. The live rule requires organic participation; current automated removal notices define eligibility as at least 5 unrelated posts or comments in `r/apple` during the previous month and keep self-promotion at no more than 10% of activity. Sources: and a current removal notice quoting Rule 9: | Hold. Do not create activity or use a promotion-only identity to satisfy the gate. Reconsider only if an already-approved `codepilotios` identity independently meets the participation ratio, the post can be made during the Sunday window, and a direct official beta or repository URL is approved. | | `r/macosprogramming` | Medium for a technical post | Posts must concern macOS software development. Open-source apps may be shared, but closed-source, paid, or IAP apps require prior moderator permission. The rules also prohibit repetitive promotion, link spam, and low-effort autogenerated posts. CodePilot is source-available under a noncommercial license rather than OSI open source, so the open-source exception must not be assumed. Sources: and | Moderator-first only. Ask whether a technical, text-first discussion of the Mac menu bar app and local gateway is acceptable. A maintainer must review and send the note manually, then rewrite/review the discussion copy before posting. | | `r/indiehackers` | Medium for product validation | The live rules allow one self-promotional product post for feedback and critique rather than advertising. The rules call the required flair `SHOW IH`, while the visible flair filter currently says `Self Promotion`; moderator guidance also emphasizes contribution before promotion and rejects fake Q&A, karma farming, and artificial engagement. Sources: and | Held secondary option. Confirm the current flair and that the approved identity has genuine participation before using the one-time post. Use the link-free validation draft, disclose the maker relationship, and do not mention revenue, pricing, or ask for votes. | | `r/selfhosted` | Low-medium later | Mobile apps are allowed only as companions to a self-hosted backend. Promoted apps must be production-ready and documented. Projects under 3 months old may be posted only in the current New Project Megathread; Wednesday has a separate exception for dashboards and tools that help self-hosters. Source: | Hold for now. CodePilot has a local gateway and user-owned tunnel, but this is not primarily a general self-hosted server product; ask moderators only after public docs and production readiness are stronger. | @@ -93,6 +94,7 @@ Operational rules for this queue: - `r/macapps`: requires 10 local karma, trust/transparency qualification or monthly megathread use, and post approval. Current App Pile PCP format expects pricing and link fields, so posting needs moderator guidance while pricing/link status is intentionally withheld. - `r/OpenAI`: direct self-promotional project links are not allowed; ask mods before any non-link discussion that mentions CodePilot. - `r/MacOS`: Saturday-only from 00:00 through 23:59 UTC, one promotional post per user per week, explicit developer/affiliate disclosure, useful context, and no low-effort link drop. GitHub repositories are allowed but will be scanned, so any linked repository must be approved, accessible, and security-ready. +- `r/apple`: developer self-promotion is Sunday-only in California time and must be a substantive self-post with a direct link. Hold unless the approved identity already has at least 5 unrelated contributions in the community during the previous month and stays at or below the one-in-ten self-promotion ratio. Do not manufacture participation; approve the exact title, body, timing, and official URL before a maintainer posts manually. - `r/macosprogramming`: prior moderator permission is required because CodePilot's noncommercial source-available license is not OSI open source. Any approved post must stay focused on macOS development, be technically substantive, and be reviewed manually to avoid low-effort autogenerated copy. - `r/indiehackers`: only one product self-promotion is allowed, and it must seek feedback/critique rather than advertise. Confirm whether the live required flair is `SHOW IH` or `Self Promotion`, and wait until the approved identity has genuine community participation. Do not make revenue claims or manufacture engagement. - `r/selfhosted`: hold until CodePilot has public docs and production-ready positioning that fits the community; projects under 3 months old belong only in the current New Project Megathread, and any maker-authored mention still needs moderator and maintainer approval. @@ -124,7 +126,7 @@ approve the exact copy immediately before every manual post or comment. keep the post candid, technical, and creator-disclosed. 5. **Thread, moderator, or participation-gated communities:** consider the current `r/ChatGPTCoding` self-promotion thread, `r/AI_Agents`, `r/codex`, - `r/iOSProgramming`, `r/swift`, `r/MacOS`, `r/macosprogramming`, and + `r/iOSProgramming`, `r/swift`, `r/MacOS`, `r/apple`, `r/macosprogramming`, and `r/indiehackers` only after their thread, activity, timing, moderator, account-filter, and maintainer gates are satisfied. Keep all other held targets out of the launch queue unless their @@ -866,6 +868,51 @@ Repository: [APPROVED PUBLIC REPOSITORY URL] I am looking for architecture and security feedback, not votes or promotional amplification. Please do not share credentials, gateway tokens, private hostnames, logs, or project files in replies. ``` +## Draft 20: `r/apple` Developer Sunday Self-Post + +Posting target: `r/apple`, only during the community's Sunday window in +California time. + +Approval status: hold until an approved `codepilotios` identity already has at +least 5 unrelated posts or comments in `r/apple` during the previous month and +self-promotion remains at or below 10% of its community activity. Do not create +an account or manufacture participation to meet this gate. + +Posting gates: +- Use a substantive text self-post, not a direct link post. +- Include one direct official beta, App Store, or privacy-audited repository URL; + the placeholder below must never be posted. +- Disclose the maker relationship in the opening sentence. +- Explain the Apple-platform use case and invite specific beta feedback rather + than votes, follows, or messages. +- A maintainer must confirm eligibility, Sunday timing, the exact copy, and the + official URL immediately before posting manually. + +Title: + +```text +I built a Mac + iPhone companion for checking and steering Codex sessions away from my desk +``` + +Body: + +```text +I am the maker of CodePilot, a beta Mac menu bar app and iPhone companion for people who run Codex on a Mac they control. + +CodePilot keeps the coding-agent session, credentials, and project files on the Mac. A token-protected loopback gateway gives the iPhone app narrow access to session status, threads, file uploads, notifications, and controls for steering or stopping an active turn. For remote access in the current beta, the tester uses a Cloudflare Tunnel they control. + +I am looking for specific Apple-platform beta feedback: + +- Is the handoff from Mac setup to iPhone pairing understandable without live help? +- Which away-from-desk action is actually useful: status, notifications, steering, stopping, or file upload? +- What security or recovery explanation would you expect before connecting an iPhone to a local Mac gateway? +- Which setup step would make you abandon the beta? + +This is a feedback request, not an upvote request. Please do not share credentials, gateway tokens, private hostnames, logs, screenshots containing private project data, or project files in replies. + +Official beta or repository link: [APPROVED_DIRECT_OFFICIAL_URL] +``` + ## Hacker News / Show HN Outline Approval status: hold. HN is temporarily restricting Show HN submissions to @@ -961,3 +1008,6 @@ this run did not create an account or manufacture participation. The new `r/LLMDevs` technical draft is held until a maintainer confirms that the live noncommercial-license exception covers the PolyForm Noncommercial License and approves an accessible, privacy-audited repository URL. +The new `r/apple` Developer Sunday draft is held until the approved identity +already meets the community's organic-participation and one-in-ten promotion +gates and a maintainer approves the timing, exact copy, and direct official URL. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index ecb1da99..6c12baa3 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -62,6 +62,12 @@ Status: maintainer action required before any public posting. this launch. Reconsider only if an existing approved public identity is eligible, the beta is directly usable, and a maintainer manually rewrites the prepared outline. +- Hold `r/apple` unless the approved identity already has at least 5 unrelated + contributions there during the previous month and remains at or below the + community's one-in-ten self-promotion ratio. Developer promotion is limited + to substantive self-posts on Sunday in California time and requires a direct + link. Approve the exact copy, timing, and official URL before a maintainer + posts manually; do not manufacture participation to qualify. - Treat `r/macosprogramming` as moderator-first. CodePilot is source-available under a noncommercial license, not OSI open source, so its open-source posting exception must not be assumed. A maintainer must approve and manually send the From fdb000d9d82cfb6966b38935580acfa0432fd9f8 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 20:23:14 +0200 Subject: [PATCH 49/57] docs: add ChatGPTPro community draft --- .../2026-07-01-codepilot-beta-drafts.md | 59 ++++++++++++++++++- ops/agents/escalations/community-drafts.md | 6 ++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index b22ca201..1ab72cab 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -59,6 +59,7 @@ Operational rules for this queue: | `r/SwiftUI` | Low-medium for technical discussion only | Posts must be specifically about SwiftUI rather than general app feedback. App promotion is permitted only when source code is also provided, preferably through GitHub; low-effort showcases and questions without relevant code are discouraged. Source: | Do not use for tester recruitment. Consider only a focused SwiftUI implementation discussion after the relevant source, code excerpt, and public repository have passed launch review. Maintainer approval is still required. | | `r/macapps` | Medium | Current front page shows active app posts with pricing/flair labels and the July 2026 App Pile megathread. Rule/sidebar snippets require 10 local karma, megathread promotion unless the account qualifies through trust/transparency, post approval, and PCP-style promotion. Recent megathreads require pricing and link fields. Sources: and | Hold. Current pricing/link requirements conflict with the no-pricing/no-unapproved-link launch constraint unless moderators approve a beta-feedback exception. | | `r/OpenAI` | Medium-low | Search-visible rule text says self-promotion should stay under a 1/10 guideline, direct self-promotional project links are not allowed, and promotional project posts need context in text posts. Other current rule snippets show stricter enforcement language against solicitation/self-promotion. Sources: and | Approval required. Avoid direct link posts. Consider only a non-link discussion post after moderator approval. | +| `r/ChatGPTPro` | Medium for a technical power-user discussion | The community is for advanced, professional LLM workflows rather than basic support or generic promotion. Its published policy limits self-promotion to at most 10% of an account's community activity, requires clear value and affiliation disclosure, and recent removal notices show active enforcement against direct promotional links and accounts without enough non-promotional participation. Sources: , , and | Hold until the approved identity has genuine technical participation and safely meets the one-in-ten limit. Ask moderators before a maker-authored post, use the link-free architecture/workflow draft only if approved, and do not treat the community as a tester-recruitment feed. | | `r/MacOS` | Medium | The March 2026 Developer Saturday policy permits one self-promotional post per user each Saturday from 00:00 through 23:59 UTC. It explicitly welcomes accessible GitHub repositories, which are scanned by GitHub-Guard, and requires affiliation disclosure, useful context, and more than a low-effort link drop. Source: | Candidate Saturday placement after maintainer approval. Use a transparent, context-rich beta-feedback post and link only to an approved, accessible, security-ready repository or distribution page. | | `r/apple` | Medium reach, low immediate eligibility | Developer app self-promotion is allowed only on Sundays in California time and must be a substantive self-post with a link. The live rule requires organic participation; current automated removal notices define eligibility as at least 5 unrelated posts or comments in `r/apple` during the previous month and keep self-promotion at no more than 10% of activity. Sources: and a current removal notice quoting Rule 9: | Hold. Do not create activity or use a promotion-only identity to satisfy the gate. Reconsider only if an already-approved `codepilotios` identity independently meets the participation ratio, the post can be made during the Sunday window, and a direct official beta or repository URL is approved. | | `r/macosprogramming` | Medium for a technical post | Posts must concern macOS software development. Open-source apps may be shared, but closed-source, paid, or IAP apps require prior moderator permission. The rules also prohibit repetitive promotion, link spam, and low-effort autogenerated posts. CodePilot is source-available under a noncommercial license rather than OSI open source, so the open-source exception must not be assumed. Sources: and | Moderator-first only. Ask whether a technical, text-first discussion of the Mac menu bar app and local gateway is acceptable. A maintainer must review and send the note manually, then rewrite/review the discussion copy before posting. | @@ -126,8 +127,8 @@ approve the exact copy immediately before every manual post or comment. keep the post candid, technical, and creator-disclosed. 5. **Thread, moderator, or participation-gated communities:** consider the current `r/ChatGPTCoding` self-promotion thread, `r/AI_Agents`, `r/codex`, - `r/iOSProgramming`, `r/swift`, `r/MacOS`, `r/apple`, `r/macosprogramming`, and - `r/indiehackers` only after their thread, + `r/ChatGPTPro`, `r/iOSProgramming`, `r/swift`, `r/MacOS`, `r/apple`, + `r/macosprogramming`, and `r/indiehackers` only after their thread, activity, timing, moderator, account-filter, and maintainer gates are satisfied. Keep all other held targets out of the launch queue unless their rules or CodePilot's release status materially change. @@ -913,6 +914,56 @@ This is a feedback request, not an upvote request. Please do not share credentia Official beta or repository link: [APPROVED_DIRECT_OFFICIAL_URL] ``` +## Draft 21: `r/ChatGPTPro` Technical Power-User Discussion + +Posting target: `r/ChatGPTPro` main feed. + +Approval status: hold until the approved `codepilotios` identity has genuine +non-promotional technical participation in the community, self-promotion would +remain at or below 10% of its activity there, and moderators approve the exact +maker-authored discussion. Do not create activity solely to satisfy this gate. + +Rule notes: +- Keep the post about an advanced Codex workflow and its trust boundaries, not a + generic app announcement or tester request. +- Disclose the maker relationship in the first sentence. +- Include no product, repository, TestFlight, signup, or direct-message link. +- Do not ask for installs, votes, follows, or private outreach. +- A maintainer must re-check the live rules and recent removals, send any + moderator note, approve the exact copy, and post manually. + +Moderator note: + +```text +Hi mods, I am the maker of CodePilot, a beta Mac + iPhone companion for people who run Codex on a Mac they control. + +I understand that r/ChatGPTPro is for advanced technical workflows and that disclosed self-promotion must stay within the one-in-ten limit and provide clear community value. Would a link-free technical post about the trust and control boundaries of remotely monitoring and steering a local Codex session be acceptable if I disclose my affiliation and ask for architecture criticism rather than testers? + +I will not post it unless you approve the format, and I will not include a product, signup, repository, or beta link. +``` + +Title: + +```text +Where should the trust boundary sit when steering a coding agent away from the workstation? +``` + +Body: + +```text +I am the maker of CodePilot, a beta Mac + iPhone companion for people who run Codex on a Mac they control. + +The workflow I am testing keeps the Codex session, provider credentials, and project files on the Mac. A token-protected loopback gateway exposes only a narrow set of remote actions: read session status, view threads, receive notifications, upload a file, and steer or stop an active turn. The current remote path uses a Cloudflare Tunnel controlled by the tester. + +The hard product question is not whether remote control is technically possible; it is which controls should be available without weakening the workstation's trust boundary. I would value power-user feedback on three choices: + +- Which away-from-desk actions should remain read-only, require fresh confirmation, or never be exposed remotely? +- Is a user-owned tunnel plus a loopback gateway understandable enough to operate safely, or does the setup burden erase the benefit? +- Which audit and recovery details would help diagnose a failure without recording prompts, tokens, private hostnames, or project contents? + +This is an architecture and workflow question, not an install or upvote request. Please do not share credentials, gateway tokens, private logs, hostnames, screenshots, or project files in replies. +``` + ## Hacker News / Show HN Outline Approval status: hold. HN is temporarily restricting Show HN submissions to @@ -1011,3 +1062,7 @@ License and approves an accessible, privacy-audited repository URL. The new `r/apple` Developer Sunday draft is held until the approved identity already meets the community's organic-participation and one-in-ten promotion gates and a maintainer approves the timing, exact copy, and direct official URL. +The new `r/ChatGPTPro` technical draft is held until the approved identity has +genuine non-promotional participation, remains within the community's +one-in-ten self-promotion limit, and a maintainer obtains moderator approval for +the exact link-free copy. diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 6c12baa3..2c825a1b 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -89,6 +89,12 @@ Status: maintainer action required before any public posting. promotion-only account; re-check the live About page and thread first. - If choosing `r/codex`, `r/OpenAI`, `r/macapps`, or another moderator-first target, send the prepared modmail manually and wait for approval. +- Hold `r/ChatGPTPro` until the approved identity has genuine non-promotional + technical participation and a CodePilot post would remain at or below 10% of + its community activity. A maintainer must re-check recent enforcement, + approve and manually send the prepared moderator note, then approve and post + the exact link-free technical discussion. Do not manufacture participation or + use the community as a tester-recruitment feed. No post, comment, profile change, account action, or moderator message was made by the community-drafts agent. From 95ebdea8fb52ff3bc0bbea916f35f633c2866ee0 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 21:38:10 +0200 Subject: [PATCH 50/57] docs: prepare beta community outreach drafts --- .../2026-07-18-community-research.md | 53 ++++ .../2026-07-18-draft-posts-and-comments.md | 240 ++++++++++++++++++ docs/community/README.md | 30 +++ .../community/profile-and-response-library.md | 86 +++++++ 4 files changed, 409 insertions(+) create mode 100644 docs/community/2026-07-18-community-research.md create mode 100644 docs/community/2026-07-18-draft-posts-and-comments.md create mode 100644 docs/community/README.md create mode 100644 docs/community/profile-and-response-library.md diff --git a/docs/community/2026-07-18-community-research.md b/docs/community/2026-07-18-community-research.md new file mode 100644 index 00000000..d5eb374b --- /dev/null +++ b/docs/community/2026-07-18-community-research.md @@ -0,0 +1,53 @@ +# Community Research and Approval Queue + +Research checked on 2026-07-18. Community rules change; re-check each destination +immediately before requesting approval to post. + +## Recommended initial queue + +| Priority | Community | Fit | Current constraints | Approval state | +| --- | --- | --- | --- | --- | +| 1 | r/TestFlight | Direct access to beta testers | Link posts must point only to TestFlight; title should be the app name with at most a short message; use the app flair; keep comments on the beta; avoid repeat posts. | Hold until a public TestFlight link is tested, then maintainer approves the exact post. | +| 2 | r/ChatGPTCoding | AI coding users and agent-tool builders | Promotion belongs in the designated self-promotion thread; flair is required for main posts; promote a project only once in the thread's stated policy. Main-feed promotion requires moderator sponsorship through modmail. | Maintainer approval required. Use only the current self-promotion thread, not the main feed. | +| 3 | r/Codex | Best audience match for the Codex-focused beta | Content must be directly related to Codex, use the right flair, and avoid bots. The rules do not explicitly permit self-promotion. | Moderator approval and maintainer approval required before any promotional post. A human must post; do not automate. | +| 4 | r/iOSProgramming | Useful for a technical build retrospective | App promotion is allowed only on Saturday, limited to one app once per year, and discouraged without previous community activity. The post should contain technical substance rather than an advertisement. | Maintainer approval required after confirming account history, Saturday timing, and unused annual allowance. | + +## Secondary or hold destinations + +| Community | Assessment | Decision | +| --- | --- | --- | +| r/MacApps | Relevant to the menu-bar app, but the community requires 10 local karma, qualification or a monthly megathread, a prescribed post template, and explicit post approval. Promotion is limited to once per developer in 30 days. | Hold. Ask a maintainer to verify account eligibility and the current approval instructions. Never manufacture karma. | +| r/OpenAI | Relevant but broad. Self-promotion should remain under 10% of participation, must be a contextual text post rather than a direct-link post, and participation is expected before advertising. | Hold until the identity has genuine participation and a maintainer approves a contextual post. | +| r/SideProject | No community-specific rules were returned by Reddit's rules endpoint, but current community discussion strongly rejects link dropping and low-effort promotion. | Optional later build-story post only, after the focused beta communities. Keep it discussion-first and do not cross-post identical copy. | +| Show HN | A project must be personally built, directly usable, non-trivial, and ready for the author to discuss. Show HN discourages signup barriers and vote coordination. | Hold until the public beta is immediately testable and a maintainer can be present to answer questions. | +| r/SwiftUI | Promotion is allowed only when source code is provided and the app is open source. CodePilot is source-available under a noncommercial license, not OSI open source. | Do not promote CodePilot there under the current rules. | +| r/iOSApps | Requires 10 local karma, disclosure, ABC-format promotion, official App Store/TestFlight links, and either qualification or the monthly megathread. Its rules also exclude generative-AI or AI-wrapped apps. | Do not submit without explicit moderator confirmation that CodePilot is in scope; otherwise use r/TestFlight. | + +## Rule sources + +- [r/TestFlight rules](https://www.reddit.com/r/TestFlight/about/rules) +- [r/ChatGPTCoding rules](https://www.reddit.com/r/ChatGPTCoding/about/rules) +- [r/ChatGPTCoding promotion thread example](https://www.reddit.com/r/ChatGPTCoding/comments/1ryqth6/self_promotion_thread/) +- [r/Codex rules](https://www.reddit.com/r/codex/about/rules) +- [r/iOSProgramming rules](https://www.reddit.com/r/iOSProgramming/about/rules) +- [r/MacApps rules](https://www.reddit.com/r/macapps/about/rules) +- [r/OpenAI rules](https://www.reddit.com/r/OpenAI/about/rules) +- [r/SideProject rules](https://www.reddit.com/r/SideProject/about/rules) +- [r/SwiftUI rules](https://www.reddit.com/r/SwiftUI/about/rules) +- [r/iOSApps rules](https://www.reddit.com/r/iosapps/about/rules) +- [Show HN guidelines](https://news.ycombinator.com/showhn.html) + +## Required approvals + +- Every public post or comment requires final maintainer approval under the + CodePilot launch policy. +- r/Codex requires moderator confirmation because its published rules do not + explicitly authorize project promotion. +- r/iOSProgramming requires confirmation of Saturday timing, genuine prior + activity, and the once-per-year allowance. +- r/MacApps requires community post approval plus its local-karma and template + requirements. +- r/OpenAI requires genuine prior participation before self-promotion. +- r/iOSApps requires eligibility confirmation and likely moderator clarification + because CodePilot is an AI coding companion. + diff --git a/docs/community/2026-07-18-draft-posts-and-comments.md b/docs/community/2026-07-18-draft-posts-and-comments.md new file mode 100644 index 00000000..b35f8724 --- /dev/null +++ b/docs/community/2026-07-18-draft-posts-and-comments.md @@ -0,0 +1,240 @@ +# Beta-Focused Draft Posts and Comments + +All copy below is unposted. Bracketed values must be replaced and verified during +final approval. Every draft uses the public `codepilotios` identity. + +## r/TestFlight + +**Status:** Preferred first post after `[TESTFLIGHT_URL]` is publicly reachable. + +**Title** + +> CodePilot — iPhone companion for Codex CLI + +**Link target** + +> [TESTFLIGHT_URL] + +**First comment** + +> Disclosure: this is the CodePilot project account, and we build the app. +> +> CodePilot is a public beta for people who already run Codex CLI on a Mac and +> want to check threads, usage status, and account state from an iPhone. The beta +> also supports file uploads, turn controls where available, and turn-finished +> notifications through a gateway running on your own Mac. +> +> The current setup uses a user-controlled Cloudflare Tunnel for iPhone access. +> It is still a beta, so please use a trusted Mac and read the security and privacy +> notes before exposing the gateway. +> +> The feedback that would help most: +> +> - Where did setup become unclear? +> - Did the first iPhone connection work without recovery steps? +> - Which thread or turn control felt unreliable? +> +> Please do not share gateway URLs, tokens, account names, private screenshots, +> or unsanitized logs in public replies. The public issue tracker is +> [PUBLIC_REPOSITORY_URL]/issues. + +## r/ChatGPTCoding self-promotion thread + +**Status:** Use only as one comment in the current designated promotion thread. + +> Disclosure: we build CodePilot; this is its project account. +> +> We are preparing a public beta of an iPhone companion for people who already +> use Codex CLI on a Mac. CodePilot keeps the coding-agent process and credentials +> on the user's Mac, then exposes threads, usage status, file uploads, turn +> controls, and notifications to the iPhone app through a local gateway. +> +> We are looking for feedback on setup clarity and connection reliability, not a +> polished-launch verdict. If that workflow matches how you use coding agents, +> the beta is here: [TESTFLIGHT_URL] +> +> Technical details and beta limitations: [PUBLIC_REPOSITORY_URL] +> +> Please keep tokens, hostnames, account names, private screenshots, and raw logs +> out of public feedback. + +## r/Codex moderator approval request + +**Status:** Draft modmail only. A maintainer must approve sending it, and a human +must send it. Do not automate. + +**Subject** + +> Permission to share a transparent CodePilot TestFlight beta post? + +**Body** + +> Hello moderators, +> +> We maintain CodePilot, a Mac menu-bar app, local gateway, and iPhone companion +> for existing Codex CLI users. We would like to ask before sharing a single, +> clearly disclosed beta post requesting feedback on setup and reliability. +> +> The post would link to the public TestFlight beta and public source-available +> repository, make no pricing claims, and explicitly warn testers not to share +> credentials, gateway URLs, private screenshots, or unsanitized logs. We would +> use the CodePilot project identity and would not automate the post or repeat it. +> +> Is that appropriate for r/Codex? If so, which flair and format should we use? + +### r/Codex post, only if moderators approve + +**Title** + +> We built an iPhone companion for Codex CLI and are looking for beta setup feedback + +**Body** + +> Disclosure: we build CodePilot; this is the project's account. +> +> CodePilot is a Mac menu-bar app, local gateway, and iPhone companion for people +> who already run Codex CLI on a Mac. The current beta lets you inspect threads +> and usage status, upload files, steer or stop turns where supported, and receive +> turn-finished notifications from an iPhone. +> +> It does not replace Codex CLI. The coding-agent process and credentials remain +> on the Mac. Remote access currently uses a user-controlled Cloudflare Tunnel, +> so this is for testers who are comfortable reviewing the setup and security +> notes. +> +> We would especially value feedback on three things: +> +> 1. Can you connect the iPhone within five minutes after Mac setup? +> 2. Are connection and authentication failures actionable? +> 3. Which remote control feels least dependable? +> +> TestFlight: [TESTFLIGHT_URL] +> +> Technical details and known beta limits: [PUBLIC_REPOSITORY_URL] +> +> Please do not post gateway URLs, tokens, account names, private screenshots, or +> unsanitized logs. We will be here to answer technical questions and collect +> reproducible feedback. + +## r/iOSProgramming App Saturday + +**Status:** Use only on Saturday after confirming genuine prior participation and +that the account has not used the once-per-year app allowance. + +**Title** + +> Building an iPhone control surface for a coding agent running on a Mac + +**Body** + +> Disclosure: we build CodePilot; this is the project's account. +> +> We have been working on a public beta that pairs a Swift iPhone app with a +> coding-agent gateway running on the user's Mac. The interesting iOS problem was +> less about chat UI and more about making remote state understandable: active +> turns, stale authentication, file-transfer progress, background notifications, +> and controls that may or may not be supported by the current agent state. +> +> The product boundary is deliberately explicit. Codex CLI and its credentials +> stay on the Mac. The phone talks to a local gateway, with remote access currently +> routed through a user-controlled Cloudflare Tunnel. The UI has to distinguish a +> tunnel failure, an unreachable gateway, and rejected authentication without +> collapsing them into a generic connection error. +> +> We are now testing whether the onboarding explains that architecture clearly +> enough for someone who did not build it. If you test developer tools on both a +> Mac and iPhone, we would value feedback on: +> +> - connection-state wording; +> - recovery actions after an auth or gateway failure; +> - whether remote turn controls communicate their limits clearly. +> +> TestFlight: [TESTFLIGHT_URL] +> +> Source and architecture notes: [PUBLIC_REPOSITORY_URL] +> +> It is an early beta. Please do not put tokens, hostnames, account names, private +> screenshots, or unsanitized logs in public feedback. + +## r/MacApps monthly megathread + +**Status:** Hold until the account meets local-karma and approval requirements. +Adapt to the current mandatory template before approval. + +> Disclosure: we build CodePilot; this is the project's account. +> +> CodePilot is a Mac menu-bar app and iPhone companion for people who already run +> Codex CLI on a Mac. The Mac app manages saved account profiles, shows usage +> status, and coordinates a local gateway so the iPhone can inspect threads, +> upload files, control turns where supported, and receive notifications. +> +> The public beta is aimed at technical testers who are comfortable reviewing its +> setup, privacy, and security documentation. We are looking specifically for Mac +> first-run and menu-bar feedback. +> +> Project and build instructions: [PUBLIC_REPOSITORY_URL] +> TestFlight companion: [TESTFLIGHT_URL] + +## r/SideProject build-story draft + +**Status:** Optional later post; do not publish alongside the focused beta posts. + +**Title** + +> The hard part of an iPhone coding-agent companion was explaining where everything runs + +**Body** + +> Disclosure: we build CodePilot; this is the project's account. +> +> We expected the hard part of an iPhone companion for Codex CLI to be remote chat. +> In practice, the harder problem was making the system boundary legible. +> +> The agent and credentials stay on the user's Mac. A local gateway exposes the +> selected threads and controls. The iPhone is a client. Remote access currently +> uses infrastructure controlled by the tester. When something fails, the app has +> to say whether the tunnel, gateway, authentication, or active turn is the actual +> blocker. +> +> That changed our beta goal from “more features” to a measurable setup question: +> can a new tester connect the phone and send a first prompt within five minutes, +> without private support? +> +> We are preparing a public beta now. For people who have shipped a tool with a +> local service plus mobile client: what explanation or diagnostic made that +> architecture click for users? +> +> Technical context: [PUBLIC_REPOSITORY_URL] + +## Show HN + +**Status:** Hold until a maintainer confirms the beta is directly usable and can +remain present to answer questions. + +**Title** + +> Show HN: CodePilot – control Codex CLI sessions from an iPhone + +**Submission URL** + +> [PUBLIC_REPOSITORY_URL] + +**Opening comment** + +> We built CodePilot because Codex CLI sessions often keep running on a Mac while +> the person using them steps away. It is a Mac menu-bar app, local Python gateway, +> and Swift iPhone client for inspecting threads and usage, uploading files, +> steering or stopping turns where supported, and receiving completion +> notifications. +> +> The core design constraint is that Codex and its credentials remain on the Mac. +> The current remote path uses a user-controlled Cloudflare Tunnel. That makes the +> architecture inspectable, but setup and failure recovery are still rough enough +> that we are calling this a beta. +> +> The repository includes build instructions, architecture, security notes, and +> current limitations. We would value feedback on whether the trust boundary is +> understandable and whether the setup diagnostics are specific enough. +> +> Disclosure: this is the CodePilot project account, and we built it. + diff --git a/docs/community/README.md b/docs/community/README.md new file mode 100644 index 00000000..599a1a9c --- /dev/null +++ b/docs/community/README.md @@ -0,0 +1,30 @@ +# CodePilot Community Drafts + +These files are an approval-gated community launch kit for the CodePilot public +beta. They are drafts only. Do not create accounts, post, comment, send modmail, +or reuse the copy as an advertisement without a maintainer reviewing the target +community's current rules. + +Use the public `codepilotios` identity and disclose the relationship to CodePilot +in every post or comment that mentions the project. + +## Draft files + +- [Research and posting queue](2026-07-18-community-research.md) +- [Tailored posts and comments](2026-07-18-draft-posts-and-comments.md) +- [Profile and reusable replies](profile-and-response-library.md) + +## Before any approved post + +1. Re-read the live community rules and pinned posts on the day of posting. +2. Confirm that the `codepilotios` account meets community-age, karma, and + participation requirements without manufacturing engagement. +3. Replace bracketed placeholders with public, tested URLs only. +4. Confirm that the current TestFlight build and public documentation match every + product claim. +5. Remove any screenshot, log, hostname, token, account name, or local path that + has not passed the privacy audit. +6. Obtain explicit maintainer approval for the final post and destination. +7. Post once. Do not coordinate votes, repeat the same promotion across threads, + or use automated posting. + diff --git a/docs/community/profile-and-response-library.md b/docs/community/profile-and-response-library.md new file mode 100644 index 00000000..c9ba74b7 --- /dev/null +++ b/docs/community/profile-and-response-library.md @@ -0,0 +1,86 @@ +# Public Profile and Response Library + +These are drafts for the `codepilotios` identity. Do not create or modify an +account, profile, post, or comment without explicit maintainer approval. + +## Profile bio + +> Official project account for CodePilot, a Mac and iPhone companion for Codex +> CLI. Sharing transparent public-beta updates and collecting technical feedback. +> Built by the CodePilot maintainers. + +## Short disclosure + +> Disclosure: we build CodePilot; this is the project's account. + +Use the disclosure in every post or comment that mentions or links CodePilot, +even when someone else asks for a recommendation. + +## Reusable replies + +### What does it replace? + +> It does not replace Codex CLI. Codex continues to run on your Mac. CodePilot adds +> a menu-bar app, a local gateway, and an iPhone client for remote visibility and +> controls. Disclosure: we build CodePilot. + +### Where do credentials go? + +> The current beta does not require a hosted CodePilot service. Codex credentials +> remain on the Mac. The iPhone authenticates to the user's gateway. Remote testers +> should review the security notes before exposing that gateway. Disclosure: we +> build CodePilot. + +### Why does the beta use Cloudflare Tunnel? + +> The Mac gateway listens on local loopback by default. The current public-beta +> setup uses a tunnel controlled by the tester so the iPhone can reach it without +> opening an inbound port. It adds setup complexity, which is one of the areas we +> are actively testing. Disclosure: we build CodePilot. + +### Is it open source? + +> It is source-available for noncommercial use, but it is not OSI open source +> because commercial use is restricted. The repository includes the exact license +> terms. Disclosure: we build CodePilot. + +### Does it support other coding agents? + +> Not in the first beta. The public name is provider-neutral, but the current +> implementation is focused on Codex CLI. We do not want to promise broader +> provider support before the Codex setup and reliability work is stable. +> Disclosure: we build CodePilot. + +### How should a tester report a problem? + +> Please include the step that failed, what you expected, what happened, and the +> app/build version. Do not post gateway URLs, bearer tokens, account names, +> private screenshots, local paths, or raw logs. Sanitize first, then use the +> public issue tracker at [PUBLIC_REPOSITORY_URL]/issues. Disclosure: we build +> CodePilot. + +### What kind of feedback is useful? + +> The most useful beta feedback is concrete: where setup became unclear, whether +> recovery text led to a fix, and which thread or turn action did not match your +> expectation. Reproduction steps help much more than a general rating. +> Disclosure: we build CodePilot. + +### What will it cost? + +> The current community outreach is only about the public beta and product +> reliability. We are not making pricing claims here. Disclosure: we build +> CodePilot. + +## Moderation-safe response principles + +- Answer the question before mentioning CodePilot. +- Mention CodePilot only when it is directly relevant; never hijack another + developer's post or an unrelated support thread. +- Disclose the relationship every time. +- Do not debate removals in public. If moderators request a change, stop and ask + a maintainer to review it. +- Do not ask for upvotes, coordinated comments, referrals, or private contact. +- Do not repeat a link after someone declines or expresses disinterest. +- Turn reproducible product feedback into a GitHub issue only after removing all + private information. From ea9bd2e7642908c32e3e896785e9da5081bf001e Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 22:38:25 +0200 Subject: [PATCH 51/57] docs: refresh community promotion rules --- .../2026-07-18-community-research.md | 15 ++++++----- .../2026-07-18-draft-posts-and-comments.md | 27 ++++++++++--------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/community/2026-07-18-community-research.md b/docs/community/2026-07-18-community-research.md index d5eb374b..e9e030f3 100644 --- a/docs/community/2026-07-18-community-research.md +++ b/docs/community/2026-07-18-community-research.md @@ -16,12 +16,12 @@ immediately before requesting approval to post. | Community | Assessment | Decision | | --- | --- | --- | -| r/MacApps | Relevant to the menu-bar app, but the community requires 10 local karma, qualification or a monthly megathread, a prescribed post template, and explicit post approval. Promotion is limited to once per developer in 30 days. | Hold. Ask a maintainer to verify account eligibility and the current approval instructions. Never manufacture karma. | +| r/MacApps | Relevant to the menu-bar app, but the community requires 10 local karma and limits promotion to once per developer in 30 days. Main-feed promotion also requires an established trust signal or a public transparency path; other promotions belong in the monthly App Pile megathread. | Hold for the App Pile only. A maintainer must verify local-karma eligibility, the current megathread, and its required format. Never manufacture karma or expose a private identity to qualify. | | r/OpenAI | Relevant but broad. Self-promotion should remain under 10% of participation, must be a contextual text post rather than a direct-link post, and participation is expected before advertising. | Hold until the identity has genuine participation and a maintainer approves a contextual post. | | r/SideProject | No community-specific rules were returned by Reddit's rules endpoint, but current community discussion strongly rejects link dropping and low-effort promotion. | Optional later build-story post only, after the focused beta communities. Keep it discussion-first and do not cross-post identical copy. | | Show HN | A project must be personally built, directly usable, non-trivial, and ready for the author to discuss. Show HN discourages signup barriers and vote coordination. | Hold until the public beta is immediately testable and a maintainer can be present to answer questions. | | r/SwiftUI | Promotion is allowed only when source code is provided and the app is open source. CodePilot is source-available under a noncommercial license, not OSI open source. | Do not promote CodePilot there under the current rules. | -| r/iOSApps | Requires 10 local karma, disclosure, ABC-format promotion, official App Store/TestFlight links, and either qualification or the monthly megathread. Its rules also exclude generative-AI or AI-wrapped apps. | Do not submit without explicit moderator confirmation that CodePilot is in scope; otherwise use r/TestFlight. | +| r/iOSApps | Requires 10 local karma, disclosure, ABC-format promotion, official App Store/TestFlight links, and either qualification or the monthly App Shelf megathread. More importantly, its current rules exclude generative-AI and AI-wrapped apps when AI is a core feature. | Do not promote CodePilot there under the current rules. Use r/TestFlight instead. | ## Rule sources @@ -31,6 +31,7 @@ immediately before requesting approval to post. - [r/Codex rules](https://www.reddit.com/r/codex/about/rules) - [r/iOSProgramming rules](https://www.reddit.com/r/iOSProgramming/about/rules) - [r/MacApps rules](https://www.reddit.com/r/macapps/about/rules) +- [r/MacApps trust and transparency update](https://www.reddit.com/r/macapps/comments/1ryaeex/rmacapps_mods_went_too_far_whats_changing_phase_3/) - [r/OpenAI rules](https://www.reddit.com/r/OpenAI/about/rules) - [r/SideProject rules](https://www.reddit.com/r/SideProject/about/rules) - [r/SwiftUI rules](https://www.reddit.com/r/SwiftUI/about/rules) @@ -45,9 +46,9 @@ immediately before requesting approval to post. explicitly authorize project promotion. - r/iOSProgramming requires confirmation of Saturday timing, genuine prior activity, and the once-per-year allowance. -- r/MacApps requires community post approval plus its local-karma and template - requirements. +- r/MacApps requires confirmation of 10 local karma, the current App Pile + megathread, the 30-day allowance, and the required post format. The + `codepilotios` identity must not disclose private identity details to qualify + for a main-feed transparency path. - r/OpenAI requires genuine prior participation before self-promotion. -- r/iOSApps requires eligibility confirmation and likely moderator clarification - because CodePilot is an AI coding companion. - +- r/iOSApps is excluded from the posting queue while its no-AI rule remains. diff --git a/docs/community/2026-07-18-draft-posts-and-comments.md b/docs/community/2026-07-18-draft-posts-and-comments.md index b35f8724..cf69fc12 100644 --- a/docs/community/2026-07-18-draft-posts-and-comments.md +++ b/docs/community/2026-07-18-draft-posts-and-comments.md @@ -156,23 +156,27 @@ that the account has not used the once-per-year app allowance. > It is an early beta. Please do not put tokens, hostnames, account names, private > screenshots, or unsanitized logs in public feedback. -## r/MacApps monthly megathread +## r/MacApps App Pile megathread -**Status:** Hold until the account meets local-karma and approval requirements. -Adapt to the current mandatory template before approval. +**Status:** Hold until the account has 10 genuine local karma, the 30-day +allowance is unused, and a maintainer has verified the current App Pile thread +and required format. Do not disclose a private identity to qualify for the main +feed. > Disclosure: we build CodePilot; this is the project's account. > -> CodePilot is a Mac menu-bar app and iPhone companion for people who already run -> Codex CLI on a Mac. The Mac app manages saved account profiles, shows usage -> status, and coordinates a local gateway so the iPhone can inspect threads, -> upload files, control turns where supported, and receive notifications. +> **Answer:** CodePilot is a Mac menu-bar app and iPhone companion for people who +> already run Codex CLI on a Mac and want to check or control a session while +> away from the computer. > -> The public beta is aimed at technical testers who are comfortable reviewing its -> setup, privacy, and security documentation. We are looking specifically for Mac -> first-run and menu-bar feedback. +> **Better:** The coding-agent process and credentials stay on the user's Mac. +> The app adds saved account profiles, usage status, a local gateway, iPhone +> thread visibility, file uploads, supported turn controls, and notifications. +> The public beta is aimed at technical testers who are comfortable reviewing +> its setup, privacy, and security documentation. > -> Project and build instructions: [PUBLIC_REPOSITORY_URL] +> **Cost:** This draft is for beta testing only; no future pricing details are +> being announced. Project and build instructions: [PUBLIC_REPOSITORY_URL] > TestFlight companion: [TESTFLIGHT_URL] ## r/SideProject build-story draft @@ -237,4 +241,3 @@ remain present to answer questions. > understandable and whether the setup diagnostics are specific enough. > > Disclosure: this is the CodePilot project account, and we built it. - From 0a472b310780a582af45f03cd0d15c8fe37149ae Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sat, 18 Jul 2026 23:28:59 +0200 Subject: [PATCH 52/57] docs: align community approval queue --- .../2026-07-18-community-research.md | 25 ++++- .../2026-07-18-draft-posts-and-comments.md | 104 ++++++++++++++++++ docs/community/README.md | 2 +- 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/docs/community/2026-07-18-community-research.md b/docs/community/2026-07-18-community-research.md index e9e030f3..5a49e97d 100644 --- a/docs/community/2026-07-18-community-research.md +++ b/docs/community/2026-07-18-community-research.md @@ -7,10 +7,13 @@ immediately before requesting approval to post. | Priority | Community | Fit | Current constraints | Approval state | | --- | --- | --- | --- | --- | -| 1 | r/TestFlight | Direct access to beta testers | Link posts must point only to TestFlight; title should be the app name with at most a short message; use the app flair; keep comments on the beta; avoid repeat posts. | Hold until a public TestFlight link is tested, then maintainer approves the exact post. | -| 2 | r/ChatGPTCoding | AI coding users and agent-tool builders | Promotion belongs in the designated self-promotion thread; flair is required for main posts; promote a project only once in the thread's stated policy. Main-feed promotion requires moderator sponsorship through modmail. | Maintainer approval required. Use only the current self-promotion thread, not the main feed. | -| 3 | r/Codex | Best audience match for the Codex-focused beta | Content must be directly related to Codex, use the right flair, and avoid bots. The rules do not explicitly permit self-promotion. | Moderator approval and maintainer approval required before any promotional post. A human must post; do not automate. | -| 4 | r/iOSProgramming | Useful for a technical build retrospective | App promotion is allowed only on Saturday, limited to one app once per year, and discouraged without previous community activity. The post should contain technical substance rather than an advertisement. | Maintainer approval required after confirming account history, Saturday timing, and unused annual allowance. | +| 1 | r/GenAiApps | Direct fit for a generative-AI iPhone workflow | Self-promotion must be contextual and useful, use the appropriate flair, and avoid duplicate or unsafe links. | Maintainer approves the exact link-free post and `iOS` flair. Add a beta link only after separate verification and approval. | +| 2 | r/TestFlight | Direct access to beta testers | Link posts must point only to TestFlight; title should be the app name with at most a short message; use the app flair; keep comments on the beta; avoid repeat posts. | Hold until a public TestFlight link is tested, then maintainer approves the exact post. | +| 3 | r/BetaTests | Focused product-beta feedback | Posting identities must be at least 24 hours old with at least 2 combined karma. Posts should explain the product and ask specific feedback questions. | Maintainer approves the exact post and confirms identity eligibility. Keep it link-free until a direct beta URL is verified. | +| 4 | r/alphaandbetausers | Broad alpha/beta tester recruitment | No community-specific rules are visible, but recent moderation behavior and Reddit-wide authenticity rules still apply. | Maintainer approves the exact link-free post after a same-day rules and removals check. | +| 5 | r/ChatGPTCoding | AI coding users and agent-tool builders | Promotion belongs in the designated self-promotion thread; flair is required for main posts; promote a project only once in the thread's stated policy. Main-feed promotion requires moderator sponsorship through modmail. | Maintainer approval required. Use only the current self-promotion thread, not the main feed. | +| 6 | r/Codex | Best audience match for the Codex-focused beta | Content must be directly related to Codex, use the right flair, and avoid bots. The rules do not explicitly permit self-promotion. | Moderator approval and maintainer approval required before any promotional post. A human must post; do not automate. | +| 7 | r/iOSProgramming | Useful for a technical build retrospective | App promotion is allowed only on Saturday, limited to one app once per year, and discouraged without previous community activity. The post should contain technical substance rather than an advertisement. | Maintainer approval required after confirming account history, Saturday timing, and unused annual allowance. | ## Secondary or hold destinations @@ -25,7 +28,11 @@ immediately before requesting approval to post. ## Rule sources +- [r/GenAiApps rules](https://www.reddit.com/r/GenAiApps/about/rules) - [r/TestFlight rules](https://www.reddit.com/r/TestFlight/about/rules) +- [r/BetaTests rules](https://www.reddit.com/r/BetaTests/about/rules) +- [r/BetaTests posting guidance](https://www.reddit.com/r/BetaTests/comments/1ufs2a9/community_rules/) +- [r/alphaandbetausers rules](https://www.reddit.com/r/alphaandbetausers/about/rules) - [r/ChatGPTCoding rules](https://www.reddit.com/r/ChatGPTCoding/about/rules) - [r/ChatGPTCoding promotion thread example](https://www.reddit.com/r/ChatGPTCoding/comments/1ryqth6/self_promotion_thread/) - [r/Codex rules](https://www.reddit.com/r/codex/about/rules) @@ -42,6 +49,13 @@ immediately before requesting approval to post. - Every public post or comment requires final maintainer approval under the CodePilot launch policy. +- r/GenAiApps requires approval of the exact text and `iOS` flair. Any later + beta link requires separate verification and approval. +- r/BetaTests requires confirmation that the identity is at least 24 hours old + and has at least 2 combined karma; do not create or manufacture activity to + qualify. +- r/alphaandbetausers requires a same-day check of its live rules and recent + removals because it publishes no community-specific rules. - r/Codex requires moderator confirmation because its published rules do not explicitly authorize project promotion. - r/iOSProgramming requires confirmation of Saturday timing, genuine prior @@ -52,3 +66,6 @@ immediately before requesting approval to post. for a main-feed transparency path. - r/OpenAI requires genuine prior participation before self-promotion. - r/iOSApps is excluded from the posting queue while its no-AI rule remains. + +The broader research archive, including held and excluded destinations, is in +[`ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md`](../../ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md). diff --git a/docs/community/2026-07-18-draft-posts-and-comments.md b/docs/community/2026-07-18-draft-posts-and-comments.md index cf69fc12..75187b4f 100644 --- a/docs/community/2026-07-18-draft-posts-and-comments.md +++ b/docs/community/2026-07-18-draft-posts-and-comments.md @@ -3,6 +3,110 @@ All copy below is unposted. Bracketed values must be replaced and verified during final approval. Every draft uses the public `codepilotios` identity. +## r/GenAiApps + +**Status:** Preferred first placement. Use the `iOS` flair and keep the post +link-free until a public beta URL is separately verified and approved. + +**Title** + +> I built an iPhone companion for Codex sessions running on your Mac — looking for beta workflow feedback + +**Body** + +> Disclosure: we build CodePilot; this is the project's account. +> +> CodePilot is a beta Mac + iPhone companion for people who already run Codex on +> a Mac they control. +> +> The idea is to leave the coding-agent session, credentials, and project files +> on the Mac while giving the iPhone a focused view of session status, threads, +> file uploads, notifications, and controls to steer or stop an active turn. The +> Mac runs a token-protected local gateway; remote access in the current beta +> uses a Cloudflare Tunnel controlled by the tester. +> +> We are trying to learn whether this solves a real generative-AI workflow +> problem or just moves a terminal-shaped problem onto a smaller screen. The +> feedback that would help most is: +> +> - when you would actually check or control a coding-agent session from your phone; +> - which remote actions feel useful versus risky; +> - whether the Mac gateway and iPhone connection model is understandable; and +> - what trust or setup concern would stop you from trying the beta. +> +> This is a feedback request, not an upvote request. Please do not share +> credentials, gateway tokens, private hostnames, private logs, screenshots, or +> project files. We will add an approved beta link only when public distribution +> is ready. + +## r/BetaTests + +**Status:** Use only after a maintainer confirms the identity is at least 24 +hours old with at least 2 combined karma. Keep the post link-free until a direct +public beta URL is verified and approved. + +**Title** + +> Looking for setup feedback on CodePilot, an iPhone companion for Codex sessions on a Mac + +**Body** + +> Disclosure: we build CodePilot; this is the project's account. +> +> We are looking for a small number of beta testers who already run Codex on +> macOS and can give specific setup feedback. +> +> CodePilot leaves the Codex session, credentials, and project files on the Mac. +> A token-protected local gateway gives the iPhone companion narrow access to +> session status, threads, file uploads, notifications, and controls to steer or +> stop an active turn. For the current public-beta remote path, the tester +> connects through a Cloudflare Tunnel they control. +> +> The beta questions we most need answered are: +> +> - Is the Mac gateway and iPhone pairing flow understandable without live help? +> - Is it clear what the gateway token protects and how to recover from a rejected token? +> - Does checking or steering a coding-agent turn from a phone solve a real workflow problem? +> - Which trust or setup concern would make you stop testing? +> +> This is a feedback request, not an upvote request. Please do not share +> credentials, gateway tokens, private hostnames, private logs, screenshots +> containing private project data, or project files in replies. + +## r/alphaandbetausers + +**Status:** Use only after a same-day review of the live rules and recent +removals. Do not promise reciprocal testing or incentives. Keep the post +link-free until a direct public beta URL is verified and approved. + +**Title** + +> Looking for Mac-based Codex users to test an iPhone companion setup + +**Body** + +> Disclosure: we build CodePilot; this is the project's account. +> +> We are looking for a small number of beta testers who already run Codex on +> macOS and can give specific feedback on a Mac-to-iPhone setup. +> +> CodePilot keeps the Codex session, credentials, and project files on the Mac. +> A token-protected local gateway gives the iPhone companion narrow access to +> session status, threads, file uploads, notifications, and controls to steer or +> stop an active turn. For remote access in the current beta, testers use a +> Cloudflare Tunnel they control. +> +> The questions we most need answered are: +> +> - Can you complete the Mac gateway and iPhone connection flow without live help? +> - Is it clear what the gateway token protects and how to recover from a rejected connection? +> - Does checking or steering a coding-agent turn from a phone solve a real workflow problem? +> - Which setup or trust concern would make you stop testing? +> +> This is a feedback request, not an upvote request. Please do not share +> credentials, gateway tokens, private hostnames, private logs, screenshots +> containing private project data, or project files in replies. + ## r/TestFlight **Status:** Preferred first post after `[TESTFLIGHT_URL]` is publicly reachable. diff --git a/docs/community/README.md b/docs/community/README.md index 599a1a9c..cc516da6 100644 --- a/docs/community/README.md +++ b/docs/community/README.md @@ -13,6 +13,7 @@ in every post or comment that mentions the project. - [Research and posting queue](2026-07-18-community-research.md) - [Tailored posts and comments](2026-07-18-draft-posts-and-comments.md) - [Profile and reusable replies](profile-and-response-library.md) +- [Comprehensive research and held-draft archive](../../ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md) ## Before any approved post @@ -27,4 +28,3 @@ in every post or comment that mentions the project. 6. Obtain explicit maintainer approval for the final post and destination. 7. Post once. Do not coordinate votes, repeat the same promotion across threads, or use automated posting. - From c68cd81e39aea0b5c8906191ed42461926bc1aee Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 00:14:11 +0200 Subject: [PATCH 53/57] docs: refresh community beta draft research --- .../2026-07-19-community-checkpoint.md | 92 +++++++++++++++++++ docs/community/README.md | 1 + 2 files changed, 93 insertions(+) create mode 100644 docs/community/2026-07-19-community-checkpoint.md diff --git a/docs/community/2026-07-19-community-checkpoint.md b/docs/community/2026-07-19-community-checkpoint.md new file mode 100644 index 00000000..3cb5c55c --- /dev/null +++ b/docs/community/2026-07-19-community-checkpoint.md @@ -0,0 +1,92 @@ +# Community Drafts Checkpoint — 2026-07-19 + +This is a research and approval checkpoint for the CodePilot public beta. No +account was created, and no post, comment, profile change, modmail, or other +public action was made. + +All proposed activity uses the public `codepilotios` identity, discloses the +maker relationship, asks for beta workflow or setup feedback, and makes no paid +subscription claim. + +## Communities researched + +The live Reddit rules were re-checked for the destinations most likely to affect +the first beta placements. The broader community survey remains in +[the July 18 research queue](2026-07-18-community-research.md). + +| Community | Current fit | Result for this beta queue | +| --- | --- | --- | +| `r/GenAiApps` | Strong | Preferred first placement. Valuable, contextual self-promotion is allowed; use an appropriate flair, avoid duplicate or low-effort promotion, and keep the first draft link-free. | +| `r/TestFlight` | Strong after distribution is ready | Hold until a direct public Apple TestFlight URL is verified and approved. New posts may contain only the TestFlight link, need the app flair, and should use the app name with no recruitment tag in the title. Put later updates in comments rather than reposting. | +| `r/BetaTests` | Strong for focused tester feedback | Use the prepared no-link draft only after a maintainer verifies the identity's age and karma eligibility and re-checks the posting guidance. | +| `r/alphaandbetausers` | Good secondary tester pool | Its rules endpoint currently publishes no community-specific rules. Use only after a same-day review of rules and recent removals; do not offer incentives or reciprocal testing. | +| `r/ChatGPTCoding` | Good audience, gated placement | Maker-authored service promotion belongs in the designated self-promotion thread. Main-feed placement requires moderator sponsorship. Confirm the current thread and identity eligibility before a human posts one approved comment. | +| `r/Codex` | Best topical match, moderator-first | Posts must be directly about Codex, use the right flair, and must not use bots. The published rules do not explicitly authorize maker promotion, so send the prepared permission request manually and post only if moderators and a maintainer approve. | +| `r/iOSProgramming` | Good technical retrospective, not recruitment-first | App promotion is limited to Saturday, one app per year, with previous activity expected. Use the technical App Saturday draft only after eligibility, timing, and the annual allowance are confirmed. | +| `r/MacApps` | Relevant but currently held | Requires 10 local karma, official distribution links, relationship disclosure, no more than one developer promotion in 30 days, and either main-feed qualification or the current promotion megathread. Do not manufacture eligibility or disclose a private identity. | +| `r/OpenAI` | Broad, participation-gated | Direct self-promotional project-link posts are not allowed; contextual text posts should stay under the one-in-ten guideline and follow genuine participation. Hold for a later moderator-approved discussion. | +| `r/SwiftUI` | Not eligible for this campaign | App promotion requires source code and describes eligible projects as open source. CodePilot is source-available under a noncommercial license, not OSI open source. Do not promote it there. | +| `r/iOSApps` | Not eligible for this campaign | Current rules prohibit generative-AI and AI-wrapped apps and direct them to `r/GenAiApps`. Do not use either the main feed or its promotion megathread for CodePilot. | + +## Drafts prepared + +The approval-ready draft library is in +[Beta-Focused Draft Posts and Comments](2026-07-18-draft-posts-and-comments.md). +It contains: + +- a link-free `r/GenAiApps` feedback post; +- no-link tester requests tailored to `r/BetaTests` and + `r/alphaandbetausers`; +- a TestFlight-only link post and safety-focused first comment; +- a single `r/ChatGPTCoding` self-promotion-thread comment; +- an `r/Codex` moderator permission request and conditional post; +- a technical `r/iOSProgramming` App Saturday post; +- held drafts for later communities, each with its eligibility gate; and +- transparent profile and reusable response copy in + [the profile and response library](profile-and-response-library.md). + +Bracketed URLs remain intentionally unresolved. They must not be replaced until +the exact public TestFlight or repository URL is reachable, privacy-audited, and +approved. + +## Rules and actions requiring approval + +Every public post or comment needs final maintainer approval under the launch +plan. The following destinations add a community-specific gate: + +- `r/TestFlight`: approve and verify the direct Apple TestFlight URL, exact + title, and app flair. +- `r/BetaTests`: confirm account age and karma eligibility. +- `r/alphaandbetausers`: perform a same-day rules and recent-removals check. +- `r/ChatGPTCoding`: confirm the designated thread is current and the identity + can participate; main-feed placement additionally requires moderator + sponsorship. +- `r/Codex`: moderator permission, correct flair, maintainer approval, and a + human-authored manual post with no automation. +- `r/iOSProgramming`: Saturday timing, genuine prior participation, and an + unused once-per-year app allowance. +- `r/MacApps`: 10 genuine local karma, official distribution, the current + promotion format, and the 30-day allowance. +- `r/OpenAI`: genuine prior participation and a compliant one-in-ten ratio; + moderator confirmation is recommended before mentioning CodePilot. + +The concise maintainer action list remains in +[`ops/agents/escalations/community-drafts.md`](../../ops/agents/escalations/community-drafts.md). + +## Live rule sources + +- +- +- +- +- +- +- +- +- +- +- + +Re-read the target's rules, pinned posts, and active promotion thread immediately +before any approved manual submission because community rules and moderation +formats can change without notice. diff --git a/docs/community/README.md b/docs/community/README.md index cc516da6..a33b8486 100644 --- a/docs/community/README.md +++ b/docs/community/README.md @@ -10,6 +10,7 @@ in every post or comment that mentions the project. ## Draft files +- [Current research checkpoint (2026-07-19)](2026-07-19-community-checkpoint.md) - [Research and posting queue](2026-07-18-community-research.md) - [Tailored posts and comments](2026-07-18-draft-posts-and-comments.md) - [Profile and reusable replies](profile-and-response-library.md) From 00054daedffd9256118fc3f9c8b30f6e73b91bf4 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 01:08:28 +0200 Subject: [PATCH 54/57] docs: align App Saturday community draft --- .../2026-07-18-community-research.md | 9 ++- .../2026-07-18-draft-posts-and-comments.md | 55 +++++++++++-------- .../2026-07-19-community-checkpoint.md | 9 ++- ops/agents/escalations/community-drafts.md | 5 ++ 4 files changed, 50 insertions(+), 28 deletions(-) diff --git a/docs/community/2026-07-18-community-research.md b/docs/community/2026-07-18-community-research.md index 5a49e97d..1322e887 100644 --- a/docs/community/2026-07-18-community-research.md +++ b/docs/community/2026-07-18-community-research.md @@ -13,7 +13,7 @@ immediately before requesting approval to post. | 4 | r/alphaandbetausers | Broad alpha/beta tester recruitment | No community-specific rules are visible, but recent moderation behavior and Reddit-wide authenticity rules still apply. | Maintainer approves the exact link-free post after a same-day rules and removals check. | | 5 | r/ChatGPTCoding | AI coding users and agent-tool builders | Promotion belongs in the designated self-promotion thread; flair is required for main posts; promote a project only once in the thread's stated policy. Main-feed promotion requires moderator sponsorship through modmail. | Maintainer approval required. Use only the current self-promotion thread, not the main feed. | | 6 | r/Codex | Best audience match for the Codex-focused beta | Content must be directly related to Codex, use the right flair, and avoid bots. The rules do not explicitly permit self-promotion. | Moderator approval and maintainer approval required before any promotional post. A human must post; do not automate. | -| 7 | r/iOSProgramming | Useful for a technical build retrospective | App promotion is allowed only on Saturday, limited to one app once per year, and discouraged without previous community activity. The post should contain technical substance rather than an advertisement. | Maintainer approval required after confirming account history, Saturday timing, and unused annual allowance. | +| 7 | r/iOSProgramming | Useful for a technical build retrospective | App promotion is allowed only on Saturday, limited to one app once per year, and requires genuine participation. Current automated moderation requires labeled Tech Stack, Development Challenge, and AI Disclosure sections. | Maintainer approval required after confirming account eligibility, Saturday timing, unused annual allowance, and all three required sections. | ## Secondary or hold destinations @@ -37,6 +37,8 @@ immediately before requesting approval to post. - [r/ChatGPTCoding promotion thread example](https://www.reddit.com/r/ChatGPTCoding/comments/1ryqth6/self_promotion_thread/) - [r/Codex rules](https://www.reddit.com/r/codex/about/rules) - [r/iOSProgramming rules](https://www.reddit.com/r/iOSProgramming/about/rules) +- [r/iOSProgramming App Saturday format update](https://www.reddit.com/r/iOSProgramming/comments/1pnivdy/proposed_update_to_app_saturday_feedback_requested/) +- [r/iOSProgramming current missing-section enforcement example](https://www.reddit.com/r/iOSProgramming/comments/1utie4g/removed/) - [r/MacApps rules](https://www.reddit.com/r/macapps/about/rules) - [r/MacApps trust and transparency update](https://www.reddit.com/r/macapps/comments/1ryaeex/rmacapps_mods_went_too_far_whats_changing_phase_3/) - [r/OpenAI rules](https://www.reddit.com/r/OpenAI/about/rules) @@ -58,8 +60,9 @@ immediately before requesting approval to post. removals because it publishes no community-specific rules. - r/Codex requires moderator confirmation because its published rules do not explicitly authorize project promotion. -- r/iOSProgramming requires confirmation of Saturday timing, genuine prior - activity, and the once-per-year allowance. +- r/iOSProgramming requires confirmation of Saturday timing, current account + eligibility, genuine prior activity, the once-per-year allowance, and the + required Tech Stack, Development Challenge, and AI Disclosure sections. - r/MacApps requires confirmation of 10 local karma, the current App Pile megathread, the 30-day allowance, and the required post format. The `codepilotios` identity must not disclose private identity details to qualify diff --git a/docs/community/2026-07-18-draft-posts-and-comments.md b/docs/community/2026-07-18-draft-posts-and-comments.md index 75187b4f..daf56986 100644 --- a/docs/community/2026-07-18-draft-posts-and-comments.md +++ b/docs/community/2026-07-18-draft-posts-and-comments.md @@ -222,8 +222,10 @@ must send it. Do not automate. ## r/iOSProgramming App Saturday -**Status:** Use only on Saturday after confirming genuine prior participation and -that the account has not used the once-per-year app allowance. +**Status:** Use only on Saturday after confirming genuine prior participation, +current account eligibility, and that the account has not used the once-per-year +app allowance. Keep all three labeled sections below; current automated +moderation removes App Saturday posts that omit any of them. **Title** @@ -233,32 +235,41 @@ that the account has not used the once-per-year app allowance. > Disclosure: we build CodePilot; this is the project's account. > -> We have been working on a public beta that pairs a Swift iPhone app with a -> coding-agent gateway running on the user's Mac. The interesting iOS problem was -> less about chat UI and more about making remote state understandable: active -> turns, stale authentication, file-transfer progress, background notifications, -> and controls that may or may not be supported by the current agent state. +> **Tech Stack** > -> The product boundary is deliberately explicit. Codex CLI and its credentials -> stay on the Mac. The phone talks to a local gateway, with remote access currently -> routed through a user-controlled Cloudflare Tunnel. The UI has to distinguish a -> tunnel failure, an unreachable gateway, and rejected authentication without -> collapsing them into a generic connection error. +> The iPhone app is built with SwiftUI. It talks to a token-protected local +> gateway running on the user's Mac. A Mac menu-bar app surfaces local Codex +> account and usage state. For remote access in the current beta, the iPhone +> reaches the gateway through a user-controlled Cloudflare Tunnel and still +> authenticates with a bearer token. > -> We are now testing whether the onboarding explains that architecture clearly -> enough for someone who did not build it. If you test developer tools on both a -> Mac and iPhone, we would value feedback on: +> The client focuses on connection setup, gateway health, thread and session +> status, file uploads, usage and account state, notifications, and supported +> controls for steering or stopping active turns. > -> - connection-state wording; -> - recovery actions after an auth or gateway failure; -> - whether remote turn controls communicate their limits clearly. +> **Development Challenge** > -> TestFlight: [TESTFLIGHT_URL] +> The hard part was making remote control useful without pretending the phone +> owns the coding-agent session. The source of truth still lives on the Mac: +> credentials, project files, active turns, and account state. The UI also has +> to distinguish a tunnel failure, an unreachable gateway, and rejected +> authentication instead of collapsing them into one generic connection error. +> +> We kept the iPhone app as a narrow gateway client and modeled each setup +> failure as a recoverable connection state. We are now testing whether someone +> who did not build the system can understand what stays local, what is remote, +> what the token protects, and which recovery action to take. +> +> **AI Disclosure** > -> Source and architecture notes: [PUBLIC_REPOSITORY_URL] +> AI-assisted. Coding agents have assisted with parts of the planning, copy, and +> implementation. Security-sensitive flows, gateway exposure, authentication, +> and release materials remain subject to human review. > -> It is an early beta. Please do not put tokens, hostnames, account names, private -> screenshots, or unsanitized logs in public feedback. +> We would value feedback from iOS and macOS developers on whether this +> architecture and its connection states are understandable. It is an early +> beta; please do not put tokens, hostnames, account names, private screenshots, +> or unsanitized logs in public feedback. ## r/MacApps App Pile megathread diff --git a/docs/community/2026-07-19-community-checkpoint.md b/docs/community/2026-07-19-community-checkpoint.md index 3cb5c55c..1ecffc87 100644 --- a/docs/community/2026-07-19-community-checkpoint.md +++ b/docs/community/2026-07-19-community-checkpoint.md @@ -22,7 +22,7 @@ the first beta placements. The broader community survey remains in | `r/alphaandbetausers` | Good secondary tester pool | Its rules endpoint currently publishes no community-specific rules. Use only after a same-day review of rules and recent removals; do not offer incentives or reciprocal testing. | | `r/ChatGPTCoding` | Good audience, gated placement | Maker-authored service promotion belongs in the designated self-promotion thread. Main-feed placement requires moderator sponsorship. Confirm the current thread and identity eligibility before a human posts one approved comment. | | `r/Codex` | Best topical match, moderator-first | Posts must be directly about Codex, use the right flair, and must not use bots. The published rules do not explicitly authorize maker promotion, so send the prepared permission request manually and post only if moderators and a maintainer approve. | -| `r/iOSProgramming` | Good technical retrospective, not recruitment-first | App promotion is limited to Saturday, one app per year, with previous activity expected. Use the technical App Saturday draft only after eligibility, timing, and the annual allowance are confirmed. | +| `r/iOSProgramming` | Good technical retrospective, not recruitment-first | App promotion is limited to Saturday and one app per year. Current enforcement requires labeled Tech Stack, Development Challenge, and AI Disclosure sections. Use the revised technical draft only after account eligibility, timing, and the annual allowance are confirmed. | | `r/MacApps` | Relevant but currently held | Requires 10 local karma, official distribution links, relationship disclosure, no more than one developer promotion in 30 days, and either main-feed qualification or the current promotion megathread. Do not manufacture eligibility or disclose a private identity. | | `r/OpenAI` | Broad, participation-gated | Direct self-promotional project-link posts are not allowed; contextual text posts should stay under the one-in-ten guideline and follow genuine participation. Hold for a later moderator-approved discussion. | | `r/SwiftUI` | Not eligible for this campaign | App promotion requires source code and describes eligible projects as open source. CodePilot is source-available under a noncommercial license, not OSI open source. Do not promote it there. | @@ -63,8 +63,9 @@ plan. The following destinations add a community-specific gate: sponsorship. - `r/Codex`: moderator permission, correct flair, maintainer approval, and a human-authored manual post with no automation. -- `r/iOSProgramming`: Saturday timing, genuine prior participation, and an - unused once-per-year app allowance. +- `r/iOSProgramming`: Saturday timing, current account eligibility, genuine + prior participation, an unused once-per-year app allowance, and the required + Tech Stack, Development Challenge, and AI Disclosure sections. - `r/MacApps`: 10 genuine local karma, official distribution, the current promotion format, and the 30-day allowance. - `r/OpenAI`: genuine prior participation and a compliant one-in-ten ratio; @@ -82,6 +83,8 @@ The concise maintainer action list remains in - - - +- +- - - - diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 2c825a1b..7bc4a00c 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -28,6 +28,11 @@ Status: maintainer action required before any public posting. community-specific rules, but the rules and recent removals must be checked again immediately before posting. Do not add reciprocal-testing promises, rewards, or an unapproved beta link. +- For `r/iOSProgramming`, confirm Saturday timing, the identity's current + participation and account eligibility, and that its once-per-year app + allowance is unused. Approve the revised technical draft with all three + labeled sections—Tech Stack, Development Challenge, and AI Disclosure—kept + intact; current automated moderation removes posts missing any section. - Hold `r/BetaTestersNeeded`. It is restricted to approved contributors and requires a tester perk in the title and body plus a signup/test link. Do not request approval, invent a perk, imply paid-tier value, or add an unverified From 93cfb143d6d90221700986b2de60ca507d925c24 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 02:33:58 +0200 Subject: [PATCH 55/57] docs: tighten community posting gates --- .../2026-07-18-draft-posts-and-comments.md | 5 ++- .../2026-07-19-community-checkpoint.md | 32 ++++++++++--------- ops/agents/escalations/community-drafts.md | 6 ++-- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/community/2026-07-18-draft-posts-and-comments.md b/docs/community/2026-07-18-draft-posts-and-comments.md index daf56986..b00326b9 100644 --- a/docs/community/2026-07-18-draft-posts-and-comments.md +++ b/docs/community/2026-07-18-draft-posts-and-comments.md @@ -144,7 +144,10 @@ link-free until a direct public beta URL is verified and approved. ## r/ChatGPTCoding self-promotion thread -**Status:** Use only as one comment in the current designated promotion thread. +**Status:** Use only as one comment in the currently visible designated +promotion thread after a same-day check that it remains open and that the +identity can participate without an account-karma removal or manual-review +hold. Do not use this copy as a main-feed post. > Disclosure: we build CodePilot; this is its project account. > diff --git a/docs/community/2026-07-19-community-checkpoint.md b/docs/community/2026-07-19-community-checkpoint.md index 1ecffc87..e0570ab5 100644 --- a/docs/community/2026-07-19-community-checkpoint.md +++ b/docs/community/2026-07-19-community-checkpoint.md @@ -20,7 +20,7 @@ the first beta placements. The broader community survey remains in | `r/TestFlight` | Strong after distribution is ready | Hold until a direct public Apple TestFlight URL is verified and approved. New posts may contain only the TestFlight link, need the app flair, and should use the app name with no recruitment tag in the title. Put later updates in comments rather than reposting. | | `r/BetaTests` | Strong for focused tester feedback | Use the prepared no-link draft only after a maintainer verifies the identity's age and karma eligibility and re-checks the posting guidance. | | `r/alphaandbetausers` | Good secondary tester pool | Its rules endpoint currently publishes no community-specific rules. Use only after a same-day review of rules and recent removals; do not offer incentives or reciprocal testing. | -| `r/ChatGPTCoding` | Good audience, gated placement | Maker-authored service promotion belongs in the designated self-promotion thread. Main-feed placement requires moderator sponsorship. Confirm the current thread and identity eligibility before a human posts one approved comment. | +| `r/ChatGPTCoding` | Good audience, gated placement | Maker-authored service promotion belongs in the designated self-promotion thread. The currently visible thread still receives comments, but its automated moderation actively removes or holds comments based on account karma. Main-feed placement requires moderator sponsorship. Confirm thread status and identity eligibility before a human posts one approved comment. | | `r/Codex` | Best topical match, moderator-first | Posts must be directly about Codex, use the right flair, and must not use bots. The published rules do not explicitly authorize maker promotion, so send the prepared permission request manually and post only if moderators and a maintainer approve. | | `r/iOSProgramming` | Good technical retrospective, not recruitment-first | App promotion is limited to Saturday and one app per year. Current enforcement requires labeled Tech Stack, Development Challenge, and AI Disclosure sections. Use the revised technical draft only after account eligibility, timing, and the annual allowance are confirmed. | | `r/MacApps` | Relevant but currently held | Requires 10 local karma, official distribution links, relationship disclosure, no more than one developer promotion in 30 days, and either main-feed qualification or the current promotion megathread. Do not manufacture eligibility or disclose a private identity. | @@ -58,9 +58,9 @@ plan. The following destinations add a community-specific gate: title, and app flair. - `r/BetaTests`: confirm account age and karma eligibility. - `r/alphaandbetausers`: perform a same-day rules and recent-removals check. -- `r/ChatGPTCoding`: confirm the designated thread is current and the identity - can participate; main-feed placement additionally requires moderator - sponsorship. +- `r/ChatGPTCoding`: confirm the designated thread is still open and the + identity can participate without an account-karma removal or manual-review + hold; main-feed placement additionally requires moderator sponsorship. - `r/Codex`: moderator permission, correct flair, maintainer approval, and a human-authored manual post with no automation. - `r/iOSProgramming`: Saturday timing, current account eligibility, genuine @@ -76,19 +76,21 @@ The concise maintainer action list remains in ## Live rule sources -- -- -- -- -- -- -- +- +- +- +- +- +- +- +- +- - - -- -- -- -- +- +- +- +- Re-read the target's rules, pinned posts, and active promotion thread immediately before any approved manual submission because community rules and moderation diff --git a/ops/agents/escalations/community-drafts.md b/ops/agents/escalations/community-drafts.md index 7bc4a00c..cd99cae7 100644 --- a/ops/agents/escalations/community-drafts.md +++ b/ops/agents/escalations/community-drafts.md @@ -11,9 +11,9 @@ Status: maintainer action required before any public posting. target community's account-age, karma, and participation rules. Do not create an account solely to satisfy this request. - Do not request `r/ChatGPTCoding` sponsorship solely for this launch run. - Re-check that the current self-promotion thread remains open, confirm that - `codepilotios` can comment without an account-karma removal, and approve the - exact comment before a human posts it. + Re-check that the currently visible self-promotion thread remains open, + confirm that `codepilotios` can comment without an account-karma removal or + manual-review hold, and approve the exact comment before a human posts it. - For `r/TestFlight`, provide or approve a verified public Apple TestFlight link; the draft must contain no other link. - For `r/BetaTests`, approve the no-link tester-feedback draft and confirm that From 1ecb07c32aa072bfbc5098e45532558725e1907f Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 03:15:08 +0200 Subject: [PATCH 56/57] docs: keep TestFlight draft link-only --- docs/community/2026-07-18-draft-posts-and-comments.md | 4 ++-- docs/community/2026-07-19-community-checkpoint.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/community/2026-07-18-draft-posts-and-comments.md b/docs/community/2026-07-18-draft-posts-and-comments.md index b00326b9..f380ee17 100644 --- a/docs/community/2026-07-18-draft-posts-and-comments.md +++ b/docs/community/2026-07-18-draft-posts-and-comments.md @@ -139,8 +139,8 @@ link-free until a direct public beta URL is verified and approved. > - Which thread or turn control felt unreliable? > > Please do not share gateway URLs, tokens, account names, private screenshots, -> or unsanitized logs in public replies. The public issue tracker is -> [PUBLIC_REPOSITORY_URL]/issues. +> or unsanitized logs in public replies. Use the feedback channel included with +> the beta for anything that should not be shared in the thread. ## r/ChatGPTCoding self-promotion thread diff --git a/docs/community/2026-07-19-community-checkpoint.md b/docs/community/2026-07-19-community-checkpoint.md index e0570ab5..cbb091a5 100644 --- a/docs/community/2026-07-19-community-checkpoint.md +++ b/docs/community/2026-07-19-community-checkpoint.md @@ -37,7 +37,8 @@ It contains: - a link-free `r/GenAiApps` feedback post; - no-link tester requests tailored to `r/BetaTests` and `r/alphaandbetausers`; -- a TestFlight-only link post and safety-focused first comment; +- a TestFlight-only link post and safety-focused first comment with no + additional URL; - a single `r/ChatGPTCoding` self-promotion-thread comment; - an `r/Codex` moderator permission request and conditional post; - a technical `r/iOSProgramming` App Saturday post; From 6cc0494a206791c859cb4cbc11642c8eefffbdd0 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 04:02:27 +0200 Subject: [PATCH 57/57] docs: add thread-gated community drafts --- .../2026-07-18-community-research.md | 23 +++++-- .../2026-07-18-draft-posts-and-comments.md | 68 +++++++++++++------ .../2026-07-19-community-checkpoint.md | 15 ++++ .../2026-07-01-codepilot-beta-drafts.md | 7 +- 4 files changed, 85 insertions(+), 28 deletions(-) diff --git a/docs/community/2026-07-18-community-research.md b/docs/community/2026-07-18-community-research.md index 1322e887..180023a2 100644 --- a/docs/community/2026-07-18-community-research.md +++ b/docs/community/2026-07-18-community-research.md @@ -1,7 +1,8 @@ # Community Research and Approval Queue -Research checked on 2026-07-18. Community rules change; re-check each destination -immediately before requesting approval to post. +Initial research checked on 2026-07-18; the current `r/AI_Agents` and +`r/SideProject` threads were re-checked on 2026-07-19. Community rules change; +re-check each destination immediately before requesting approval to post. ## Recommended initial queue @@ -11,9 +12,11 @@ immediately before requesting approval to post. | 2 | r/TestFlight | Direct access to beta testers | Link posts must point only to TestFlight; title should be the app name with at most a short message; use the app flair; keep comments on the beta; avoid repeat posts. | Hold until a public TestFlight link is tested, then maintainer approves the exact post. | | 3 | r/BetaTests | Focused product-beta feedback | Posting identities must be at least 24 hours old with at least 2 combined karma. Posts should explain the product and ask specific feedback questions. | Maintainer approves the exact post and confirms identity eligibility. Keep it link-free until a direct beta URL is verified. | | 4 | r/alphaandbetausers | Broad alpha/beta tester recruitment | No community-specific rules are visible, but recent moderation behavior and Reddit-wide authenticity rules still apply. | Maintainer approves the exact link-free post after a same-day rules and removals check. | -| 5 | r/ChatGPTCoding | AI coding users and agent-tool builders | Promotion belongs in the designated self-promotion thread; flair is required for main posts; promote a project only once in the thread's stated policy. Main-feed promotion requires moderator sponsorship through modmail. | Maintainer approval required. Use only the current self-promotion thread, not the main feed. | -| 6 | r/Codex | Best audience match for the Codex-focused beta | Content must be directly related to Codex, use the right flair, and avoid bots. The rules do not explicitly permit self-promotion. | Moderator approval and maintainer approval required before any promotional post. A human must post; do not automate. | -| 7 | r/iOSProgramming | Useful for a technical build retrospective | App promotion is allowed only on Saturday, limited to one app once per year, and requires genuine participation. Current automated moderation requires labeled Tech Stack, Development Challenge, and AI Disclosure sections. | Maintainer approval required after confirming account eligibility, Saturday timing, unused annual allowance, and all three required sections. | +| 5 | r/AI_Agents | Coding-agent builders and users | Project promotion belongs in the weekly Project Display thread; links belong there rather than in standalone posts. Self-promotion should remain around one in ten contributions and must include useful context. | Hold until the identity has genuine non-promotional participation and the current thread is re-checked; then approve one no-link comment. | +| 6 | r/SideProject | Broad maker feedback | No community-specific About-page rules are visible. A current thread explicitly invites project sharing, but community discussion rejects promotion-only accounts and low-effort link dropping. | Prefer one approved no-link thread comment after genuine participation and a same-day thread/removal check. | +| 7 | r/ChatGPTCoding | AI coding users and agent-tool builders | Promotion belongs in the designated self-promotion thread; flair is required for main posts; promote a project only once in the thread's stated policy. Main-feed promotion requires moderator sponsorship through modmail. | Maintainer approval required. Use only the current self-promotion thread, not the main feed. | +| 8 | r/Codex | Best audience match for the Codex-focused beta | Content must be directly related to Codex, use the right flair, and avoid bots. The rules do not explicitly permit self-promotion. | Moderator approval and maintainer approval required before any promotional post. A human must post; do not automate. | +| 9 | r/iOSProgramming | Useful for a technical build retrospective | App promotion is allowed only on Saturday, limited to one app once per year, and requires genuine participation. Current automated moderation requires labeled Tech Stack, Development Challenge, and AI Disclosure sections. | Maintainer approval required after confirming account eligibility, Saturday timing, unused annual allowance, and all three required sections. | ## Secondary or hold destinations @@ -21,7 +24,6 @@ immediately before requesting approval to post. | --- | --- | --- | | r/MacApps | Relevant to the menu-bar app, but the community requires 10 local karma and limits promotion to once per developer in 30 days. Main-feed promotion also requires an established trust signal or a public transparency path; other promotions belong in the monthly App Pile megathread. | Hold for the App Pile only. A maintainer must verify local-karma eligibility, the current megathread, and its required format. Never manufacture karma or expose a private identity to qualify. | | r/OpenAI | Relevant but broad. Self-promotion should remain under 10% of participation, must be a contextual text post rather than a direct-link post, and participation is expected before advertising. | Hold until the identity has genuine participation and a maintainer approves a contextual post. | -| r/SideProject | No community-specific rules were returned by Reddit's rules endpoint, but current community discussion strongly rejects link dropping and low-effort promotion. | Optional later build-story post only, after the focused beta communities. Keep it discussion-first and do not cross-post identical copy. | | Show HN | A project must be personally built, directly usable, non-trivial, and ready for the author to discuss. Show HN discourages signup barriers and vote coordination. | Hold until the public beta is immediately testable and a maintainer can be present to answer questions. | | r/SwiftUI | Promotion is allowed only when source code is provided and the app is open source. CodePilot is source-available under a noncommercial license, not OSI open source. | Do not promote CodePilot there under the current rules. | | r/iOSApps | Requires 10 local karma, disclosure, ABC-format promotion, official App Store/TestFlight links, and either qualification or the monthly App Shelf megathread. More importantly, its current rules exclude generative-AI and AI-wrapped apps when AI is a core feature. | Do not promote CodePilot there under the current rules. Use r/TestFlight instead. | @@ -33,6 +35,9 @@ immediately before requesting approval to post. - [r/BetaTests rules](https://www.reddit.com/r/BetaTests/about/rules) - [r/BetaTests posting guidance](https://www.reddit.com/r/BetaTests/comments/1ufs2a9/community_rules/) - [r/alphaandbetausers rules](https://www.reddit.com/r/alphaandbetausers/about/rules) +- [r/AI_Agents rules](https://www.reddit.com/r/AI_Agents/about/) +- [r/AI_Agents current Project Display thread](https://www.reddit.com/r/AI_Agents/comments/1ukprfr/weekly_thread_project_display/) +- [r/SideProject current self-promotion thread](https://www.reddit.com/r/SideProject/comments/1utus1n/whats_everyone_working_on_lets_self_promote/) - [r/ChatGPTCoding rules](https://www.reddit.com/r/ChatGPTCoding/about/rules) - [r/ChatGPTCoding promotion thread example](https://www.reddit.com/r/ChatGPTCoding/comments/1ryqth6/self_promotion_thread/) - [r/Codex rules](https://www.reddit.com/r/codex/about/rules) @@ -58,6 +63,12 @@ immediately before requesting approval to post. qualify. - r/alphaandbetausers requires a same-day check of its live rules and recent removals because it publishes no community-specific rules. +- r/AI_Agents requires genuine non-promotional participation, a safely + compliant one-in-ten ratio, and a same-day check of the weekly Project + Display thread. Do not use a standalone project post. +- r/SideProject requires genuine participation and a same-day check of the + current self-promotion thread and recent removals. Use the no-link thread + comment rather than a standalone launch post. - r/Codex requires moderator confirmation because its published rules do not explicitly authorize project promotion. - r/iOSProgramming requires confirmation of Saturday timing, current account diff --git a/docs/community/2026-07-18-draft-posts-and-comments.md b/docs/community/2026-07-18-draft-posts-and-comments.md index f380ee17..a6573a54 100644 --- a/docs/community/2026-07-18-draft-posts-and-comments.md +++ b/docs/community/2026-07-18-draft-posts-and-comments.md @@ -297,36 +297,66 @@ feed. > being announced. Project and build instructions: [PUBLIC_REPOSITORY_URL] > TestFlight companion: [TESTFLIGHT_URL] -## r/SideProject build-story draft +## r/AI_Agents weekly Project Display thread -**Status:** Optional later post; do not publish alongside the focused beta posts. +**Status:** Hold until the `codepilotios` identity has genuine non-promotional +participation and remains safely below the community's one-in-ten +self-promotion guideline. A maintainer must confirm the current weekly thread +is open and approve this exact no-link comment before a human posts it. -**Title** +> Disclosure: we build CodePilot; this is the project's account. +> +> CodePilot is a beta Mac + iPhone companion for people who run Codex sessions +> on a Mac they control. +> +> The agent-workflow question we are exploring is what should remain possible +> after you leave the terminal. CodePilot keeps the Codex session, credentials, +> and project files on the Mac. A token-protected local gateway gives the iPhone +> client narrow access to session status, threads, file uploads, notifications, +> and controls to steer or stop an active turn. The current remote path uses a +> Cloudflare Tunnel controlled by the tester. +> +> We would value feedback from people who run coding agents on longer tasks: +> +> - Which remote actions are genuinely useful: status, steering, stopping, file upload, or none of them? +> - Which actions should always require a fresh confirmation on the Mac or phone? +> - Does a user-owned gateway and tunnel clarify the trust boundary, or just make setup harder? +> - What audit or recovery information would you expect before connecting a phone to an active agent session? +> +> This is a workflow and trust feedback request, not an upvote request. We have +> left out the beta link until public distribution is approved. Please do not +> share credentials, gateway tokens, private hostnames, logs, screenshots, or +> project files in replies. -> The hard part of an iPhone coding-agent companion was explaining where everything runs +## r/SideProject self-promotion thread -**Body** +**Status:** Optional later comment. Use only after genuine participation and a +same-day check that the current thread still explicitly invites project +sharing. Keep it link-free and do not publish it alongside focused beta posts. > Disclosure: we build CodePilot; this is the project's account. > -> We expected the hard part of an iPhone companion for Codex CLI to be remote chat. -> In practice, the harder problem was making the system boundary legible. +> We are preparing CodePilot, a beta Mac + iPhone companion for people who run +> Codex sessions on a Mac they control. > -> The agent and credentials stay on the user's Mac. A local gateway exposes the -> selected threads and controls. The iPhone is a client. Remote access currently -> uses infrastructure controlled by the tester. When something fails, the app has -> to say whether the tunnel, gateway, authentication, or active turn is the actual -> blocker. +> We built it around a specific problem: a coding-agent turn can keep running +> after you leave your desk, but we do not want to move provider credentials or +> project files to another hosted service. CodePilot leaves those on the Mac. A +> token-protected local gateway gives the iPhone client narrow access to session +> status, threads, file uploads, notifications, and controls to steer or stop an +> active turn. The current remote path uses a Cloudflare Tunnel controlled by +> the tester. > -> That changed our beta goal from “more features” to a measurable setup question: -> can a new tester connect the phone and send a first prompt within five minutes, -> without private support? +> We are looking for blunt feedback on the idea before broader distribution: > -> We are preparing a public beta now. For people who have shipped a tool with a -> local service plus mobile client: what explanation or diagnostic made that -> architecture click for users? +> - Is checking or steering a coding-agent session from a phone a real workflow, or just a smaller terminal-shaped distraction? +> - Does the Mac gateway plus user-owned tunnel model feel understandable? +> - Which part would stop you from trying the beta: setup effort, token handling, remote access, or something else? > -> Technical context: [PUBLIC_REPOSITORY_URL] +> This is a feedback request, not an upvote request. We have left out the beta +> link until public distribution is approved. Please do not share credentials, +> gateway tokens, private hostnames, logs, screenshots, or project files in +> replies. ## Show HN diff --git a/docs/community/2026-07-19-community-checkpoint.md b/docs/community/2026-07-19-community-checkpoint.md index cbb091a5..9dcdb559 100644 --- a/docs/community/2026-07-19-community-checkpoint.md +++ b/docs/community/2026-07-19-community-checkpoint.md @@ -20,6 +20,8 @@ the first beta placements. The broader community survey remains in | `r/TestFlight` | Strong after distribution is ready | Hold until a direct public Apple TestFlight URL is verified and approved. New posts may contain only the TestFlight link, need the app flair, and should use the app name with no recruitment tag in the title. Put later updates in comments rather than reposting. | | `r/BetaTests` | Strong for focused tester feedback | Use the prepared no-link draft only after a maintainer verifies the identity's age and karma eligibility and re-checks the posting guidance. | | `r/alphaandbetausers` | Good secondary tester pool | Its rules endpoint currently publishes no community-specific rules. Use only after a same-day review of rules and recent removals; do not offer incentives or reciprocal testing. | +| `r/AI_Agents` | Good agent-workflow feedback pool, participation-gated | Project promotion belongs in the current weekly Project Display thread. The community permits limited self-promotion at roughly one in ten contributions, requires substantive context, and routes project links to that thread. Use the prepared no-link comment only after genuine non-promotional participation and a same-day thread check. | +| `r/SideProject` | Broad secondary feedback pool | Its About page still publishes no community-specific rules, and a current thread explicitly invites projects and feedback. Prefer one prepared no-link comment in that thread over a standalone post, and use only after genuine participation plus a same-day thread and removal check. | | `r/ChatGPTCoding` | Good audience, gated placement | Maker-authored service promotion belongs in the designated self-promotion thread. The currently visible thread still receives comments, but its automated moderation actively removes or holds comments based on account karma. Main-feed placement requires moderator sponsorship. Confirm thread status and identity eligibility before a human posts one approved comment. | | `r/Codex` | Best topical match, moderator-first | Posts must be directly about Codex, use the right flair, and must not use bots. The published rules do not explicitly authorize maker promotion, so send the prepared permission request manually and post only if moderators and a maintainer approve. | | `r/iOSProgramming` | Good technical retrospective, not recruitment-first | App promotion is limited to Saturday and one app per year. Current enforcement requires labeled Tech Stack, Development Challenge, and AI Disclosure sections. Use the revised technical draft only after account eligibility, timing, and the annual allowance are confirmed. | @@ -40,6 +42,8 @@ It contains: - a TestFlight-only link post and safety-focused first comment with no additional URL; - a single `r/ChatGPTCoding` self-promotion-thread comment; +- no-link thread comments for `r/AI_Agents` and `r/SideProject`, each held + behind its participation and same-day thread checks; - an `r/Codex` moderator permission request and conditional post; - a technical `r/iOSProgramming` App Saturday post; - held drafts for later communities, each with its eligibility gate; and @@ -59,6 +63,13 @@ plan. The following destinations add a community-specific gate: title, and app flair. - `r/BetaTests`: confirm account age and karma eligibility. - `r/alphaandbetausers`: perform a same-day rules and recent-removals check. +- `r/AI_Agents`: confirm genuine non-promotional participation, a safely + compliant one-in-ten ratio, and that the current weekly Project Display + thread remains open. Any later approved link belongs in the thread comment, + not a standalone project post. +- `r/SideProject`: confirm genuine participation and that the current + self-promotion thread remains open; use the approved no-link comment only, + not a standalone launch post. - `r/ChatGPTCoding`: confirm the designated thread is still open and the identity can participate without an account-karma removal or manual-review hold; main-feed placement additionally requires moderator sponsorship. @@ -81,6 +92,10 @@ The concise maintainer action list remains in - - - +- +- +- +- - - - diff --git a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md index 1ab72cab..1170d124 100644 --- a/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md +++ b/ops/agents/community-drafts/2026-07-01-codepilot-beta-drafts.md @@ -2,10 +2,11 @@ Status: draft only. Do not post publicly without explicit maintainer approval. -Last rule review: 2026-07-18. +Last rule review: 2026-07-19. Review method: checked public Reddit rule/sidebar/search-visible pages, moderator -announcements, removal notices, and recent promotion threads on 2026-07-18. +announcements, removal notices, and recent promotion threads on 2026-07-18 and +re-checked the current `r/AI_Agents` and `r/SideProject` threads on 2026-07-19. Re-check each target immediately before any human-approved posting because subreddit rules and megathread formats change. @@ -49,7 +50,7 @@ Operational rules for this queue: | `r/iPhoneApps` | Low / hold until genuine participation | The live rules allow recommending your own iPhone app only when it is relevant to an existing discussion, clearly disclosed, and useful. Shared apps must be available on the Apple App Store and include details such as description and cost. A 2026-07-16 moderator announcement removed the Developer flair and says pure promotional drops will be removed; developers should participate and contribute before mentioning their app in context. Sources: and | Do not prepare a standalone launch post or use a new promotion-only account. Revisit only after the approved identity has genuine community participation and CodePilot has an approved App Store listing and status language. Use modmail, not direct moderator contact, if eligibility remains unclear. | | `r/iOSBeta` | Not eligible | Current automated removals state that posts about third-party apps in beta or TestFlight are not allowed; the community is for iOS beta discussion rather than recruiting testers for individual apps. Recent examples: and | Do not post CodePilot or a TestFlight recruitment request there. Use `r/TestFlight` after the public beta link is verified and approved. | | `r/GenAiApps` | Medium-high | The community is specifically for generative-AI apps. Current rules allow self-promotion when it provides value and context, require an appropriate flair, prohibit spam/duplicate or low-effort posts, require context for links, and prohibit unsafe links or personal information. Source: | Good text-first beta-feedback candidate after maintainer approval. Use the `iOS` flair, disclose the maker relationship, explain the Codex-specific workflow, and include no link until the public beta URL is approved. Re-check recent posts and flair options immediately before posting. | -| `r/AI_Agents` | Medium-high | The live rules permit limited self-promotion at roughly one in ten posts/comments, prohibit promotion-only accounts and low-context posts, require links to be placed in comments, and route project showcases to the weekly Project Display thread. The current weekly thread explicitly welcomes AI agents and LLM apps. Sources: and | Good agent-workflow feedback target only after the approved identity has genuine non-promotional participation. Use the weekly Project Display thread, disclose the maker relationship, and keep the first draft link-free unless a direct public beta or repository URL is separately approved. | +| `r/AI_Agents` | Medium-high | The live rules permit limited self-promotion at roughly one in ten posts/comments, prohibit promotion-only accounts and low-context posts, require links to be placed in comments, and route project showcases to the weekly Project Display thread. The current weekly thread explicitly welcomes AI agents and LLM apps. Sources: and | Good agent-workflow feedback target only after the approved identity has genuine non-promotional participation. Use the weekly Project Display thread, disclose the maker relationship, and keep the first draft link-free unless a direct public beta or repository URL is separately approved. | | `r/vibecoding` | Low / externally gated | CodePilot counts as a developer tool, and the live rules require developer-tool makers to obtain approval by joining the community on X, posting the tool there, and waiting for a moderator DM before making one introduction post. The subreddit also rejects fully generated low-effort content and requires educational detail for project posts. Sources: and | Hold. This run may not create or mutate accounts or post on non-GitHub external systems, so it cannot complete the X approval gate. Do not disguise CodePilot promotion as general discussion or a project breakdown. | | `r/codex` | High | Community is specifically for OpenAI Codex tools, including Codex CLI, IDE Extension, and Codex in the Cloud. Current rules require posts to be directly related to Codex, use the right flair, avoid low-effort rants, and not use bots. Recent AMA thread required subreddit karma for comments. Sources: and | Strong fit, but treat as moderator-approval first because the post is maker-authored promotion. A maintainer must post manually; use text-only feedback framing unless mods approve a beta link. | | `r/LLMDevs` | High for a technical project discussion | The community prioritizes substantive LLM/NLP material. Its current self-promotion clarification allows projects under noncommercial licenses without prior moderator approval, prohibits commercial promotion, and bans disguised advertising or fake discussion framing. Sources: and | Good technical candidate because CodePilot is source-available under the PolyForm Noncommercial License, but describe that license exactly rather than calling the project open source. Hold until a maintainer approves an accessible, privacy-audited repository URL and confirms that the live rule still treats this specific license as eligible. Use the transparent architecture-and-trust draft, not a generic tester request. |