diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index b5af815f..cc819beb 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -568,6 +568,7 @@ final class CodexAccountSwitcher { try fileManager.removeItem(at: settings.loginBackupAuth) } try fileManager.copyItem(at: settings.activeAuth, to: settings.loginBackupAuth) + try enforceOwnerOnlyFilePermissions(settings.loginBackupAuth) try fileManager.removeItem(at: settings.activeAuth) } loginCapturePending = true @@ -1178,6 +1179,8 @@ final class CodexAccountSwitcher { private func createFolders() { try? fileManager.createDirectory(at: settings.accountsDir, withIntermediateDirectories: true) try? fileManager.createDirectory(at: settings.backupsDir, withIntermediateDirectories: true) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: settings.accountsDir.path) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: settings.backupsDir.path) } private func recoverPendingLoginCapture() { @@ -1537,6 +1540,11 @@ final class CodexAccountSwitcher { let active = activeAccountName().replacingOccurrences(of: "/", with: "_") let backup = settings.backupsDir.appendingPathComponent("\(stamp)-\(active)-auth.json") try fileManager.copyItem(at: settings.activeAuth, to: backup) + try enforceOwnerOnlyFilePermissions(backup) + } + + private func enforceOwnerOnlyFilePermissions(_ url: URL) throws { + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } private func appendAudit(_ line: String) { @@ -3280,6 +3288,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private lazy var macPeerConnection = MacPeerConnection { [weak self] event in guard let self else { return nil } let displayFrame = CGDisplayBounds(CGMainDisplayID()) + try self.requireActiveRemoteDesktopLease(event.sessionId) try self.remoteInputInjector.handle(event, displayFrame: displayFrame) let cursor = CGEvent(source: nil)?.location ?? .zero return CGPoint( @@ -3289,6 +3298,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } private var remoteDesktopCoordinator: RemoteDesktopCoordinator? private var remoteDesktopSocketServer: RemoteDesktopSocketServer? + private var remoteSessionLeaseStore: SessionLeaseStore? private var statusItem: NSStatusItem! private let menu = NSMenu() private var setupWindowController: CodePilotSetupWindowController? @@ -3302,10 +3312,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { statusItem.button?.title = "CodePilot" menu.delegate = self statusItem.menu = menu - if RemoteDesktopHostPolicy.isEnabled { - remoteDesktopCoordinator = try? RemoteDesktopCoordinator() - } - if RemoteDesktopHostPolicy.isEnabled, let remoteDesktopCoordinator { + remoteDesktopCoordinator = try? RemoteDesktopCoordinator() + if let remoteDesktopCoordinator { + remoteSessionLeaseStore = SessionLeaseStore { deviceID in + guard let device = remoteDesktopCoordinator.pairingStore.trustedDevice(id: deviceID) else { + throw RemoteDesktopSecurityError.untrustedDevice + } + guard device.revokedAt == nil else { + throw RemoteDesktopSecurityError.deviceRevoked + } + } do { let server = RemoteDesktopSocketServer { [weak self, weak remoteDesktopCoordinator] request in guard let self, let remoteDesktopCoordinator else { @@ -3553,12 +3569,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ]) case "frame.capture": + let payload = try Self.remoteDesktopRPCPayload(request.payload) + let sessionID = try Self.remoteDesktopString(payload["sessionId"]) + try requireActiveRemoteDesktopLease(sessionID) let frame = try remoteFrameCaptureService.captureMainDisplayJPEG() return HostRPCResponse(id: request.id, status: 200, payload: frame, errorCode: nil) + case "session.nonce": + let payload = try Self.remoteDesktopRPCPayload(request.payload) + let deviceID = try Self.remoteDesktopString(payload["deviceId"]) + guard let device = coordinator.pairingStore.trustedDevice(id: deviceID) else { + throw RemoteDesktopSecurityError.untrustedDevice + } + guard device.revokedAt == nil else { + throw RemoteDesktopSecurityError.deviceRevoked + } + let nonce = try remoteDesktopLeaseStore().issueNonce(for: deviceID) + return try Self.remoteDesktopRPCJSON(request.id, ["nonce": nonce]) + + case "session.start": + let payload = try Self.remoteDesktopRPCPayload(request.payload) + let deviceID = try Self.remoteDesktopString(payload["deviceId"]) + let nonce = try Self.remoteDesktopString(payload["nonce"]) + let signature = try Self.remoteDesktopBase64(payload["signature"]) + let proof = try coordinator.pairingStore.verifyNonce( + deviceID: deviceID, + nonce: nonce, + signature: signature + ) + let lease = try remoteDesktopLeaseStore().createLease(using: proof) + coordinator.registerActiveSession(deviceID: lease.deviceId, leaseID: lease.id) + return try Self.remoteDesktopRPCCodable(request.id, lease) + case "session.signal": let payload = try Self.remoteDesktopRPCPayload(request.payload) let sessionID = try Self.remoteDesktopString(payload["sessionId"]) + try requireActiveRemoteDesktopLease(sessionID) let sequence = try Self.remoteDesktopUInt64(payload["sequence"]) let kindRaw = try Self.remoteDesktopString(payload["kind"]) guard let kind = MacPeerSignal.Kind(rawValue: kindRaw) else { @@ -3593,6 +3639,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ) case "session.end": + let payload = try Self.remoteDesktopRPCPayload(request.payload) + let sessionID = try Self.remoteDesktopString(payload["sessionId"]) + try requireActiveRemoteDesktopLease(sessionID) + remoteSessionLeaseStore?.endLease(leaseID: sessionID) + coordinator.emergencyDisconnect() macPeerConnection.disconnect() return try Self.remoteDesktopRPCJSON(request.id, ["ok": true]) @@ -3603,6 +3654,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } let decoder = JSONDecoder() let event = try decoder.decode(RemoteInputEvent.self, from: request.payload) + try requireActiveRemoteDesktopLease(event.sessionId) + try remoteDesktopLeaseStore().validateSequence(event.sequence, for: event.sessionId) let displayFrame = CGDisplayBounds(CGMainDisplayID()) try remoteInputInjector.handle(event, displayFrame: displayFrame) let cursor = CGEvent(source: nil)?.location ?? .zero @@ -3626,6 +3679,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + private func remoteDesktopLeaseStore() throws -> SessionLeaseStore { + guard let remoteSessionLeaseStore else { + throw RemoteDesktopSecurityError.leaseUnknown + } + return remoteSessionLeaseStore + } + + private func requireActiveRemoteDesktopLease(_ leaseID: String) throws { + guard let activeLease = try remoteDesktopLeaseStore().activeLease(), + activeLease.id == leaseID else { + throw RemoteDesktopSecurityError.leaseUnknown + } + } + private static func remoteDesktopRPCPayload(_ data: Data) throws -> [String: Any] { if data.isEmpty { return [:] @@ -3695,6 +3762,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return "invalid_signature" case .deviceRevoked, .untrustedDevice: return "untrusted_device" + case .leaseExpired: + return "session_expired" + case .leaseUnknown: + return "unauthorized" + case .sequenceReplay: + return "sequence_replay" + case .controllerBusy: + return "controller_busy" default: return "invalid_request" } diff --git a/docs/index.md b/docs/index.md index 6f4d68bd..c4b4c473 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,15 +8,9 @@ The beta is focused on practical workflows for AI coding users: - See active Codex usage, reset timing, available reset credits, 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 through a user-owned Cloudflare Tunnel during the public beta. -- Upload files, inspect threads, steer or stop turns where supported, and receive turn-finished notifications when APNs is configured for the gateway. -- Open loopback web links from Codex output in the iPhone app through the authenticated Mac gateway. - -## Beta Access - -The Mac app currently builds from source on macOS 13 or later. The iPhone companion requires iOS 17 or later and access to an approved beta build; CodePilot does not yet advertise a public TestFlight invitation or App Store download. - -Start with the [Mac installation guide](INSTALL_MAC.md). If you already have an approved iPhone beta build, continue with the [iOS installation guide](INSTALL_IOS.md). +- Connect the iPhone app through a user-owned Cloudflare Tunnel during the public beta. +- 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. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index d37f59f8..1bd562d3 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -66,7 +66,6 @@ MAX_TOTAL_ATTACHMENT_BYTES = 50 * 1024 * 1024 MAX_JSON_BODY_BYTES = 1 * 1024 * 1024 MAX_ATTACHMENT_REQUEST_BYTES = 72 * 1024 * 1024 -UPLOAD_RETENTION_SECONDS = 7 * 24 * 60 * 60 VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} PUBLIC_JOB_TEXT_LIMIT = 4_000 PUBLIC_EVENT_BODY_LIMIT = 12_000 @@ -405,21 +404,11 @@ def _reader_loop(self): def read_or_create_token(path: Path) -> str: + path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(path.parent, 0o700) if path.exists(): - metadata = path.lstat() - if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): - raise RuntimeError("Gateway token path must be a regular file") os.chmod(path, 0o600) - token = path.read_text(encoding="utf-8").strip() - if not token: - raise RuntimeError("Gateway token file is empty") - if len(token) < MIN_GATEWAY_TOKEN_LENGTH or re.fullmatch(r"[A-Za-z0-9_-]+", token) is None: - raise RuntimeError( - f"Gateway token must contain at least {MIN_GATEWAY_TOKEN_LENGTH} URL-safe characters; rotate it before restarting" - ) - return token - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chmod(path.parent, 0o700) + return path.read_text(encoding="utf-8").strip() token = secrets.token_urlsafe(32) descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as handle: @@ -776,80 +765,29 @@ def safe_filename(name: str, fallback: str) -> str: return base[:160] or fallback -def is_image_attachment(path: Path, mime_type: str) -> bool: - if mime_type.startswith("image/"): - return True - return path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} - - -def cleanup_expired_uploads( - root: Path | None = None, - *, - now: float | None = None, - retention_seconds: int = UPLOAD_RETENTION_SECONDS, -) -> int: - """Remove expired upload batches without following links outside the upload root.""" - if retention_seconds <= 0: - raise ValueError("Upload retention must be positive") - root = DEFAULT_UPLOADS_DIR if root is None else root - +def path_is_within(path: Path, base: Path) -> bool: try: - root_stat = root.lstat() - except FileNotFoundError: - return 0 - if stat.S_ISLNK(root_stat.st_mode) or not stat.S_ISDIR(root_stat.st_mode): - return 0 - - cutoff = (time.time() if now is None else now) - retention_seconds - removed = 0 - for thread_dir in root.iterdir(): - try: - thread_stat = thread_dir.lstat() - except FileNotFoundError: - continue - if stat.S_ISLNK(thread_stat.st_mode) or not stat.S_ISDIR(thread_stat.st_mode): - continue - - for upload_dir in thread_dir.iterdir(): - try: - upload_stat = upload_dir.lstat() - except FileNotFoundError: - continue - if ( - stat.S_ISLNK(upload_stat.st_mode) - or not stat.S_ISDIR(upload_stat.st_mode) - or upload_stat.st_mtime > cutoff - ): - continue - shutil.rmtree(upload_dir) - removed += 1 - - try: - thread_dir.rmdir() - except OSError: - pass - return removed + path.relative_to(base) + return True + except ValueError: + return False -def ensure_private_upload_directory(path: Path, *, parents: bool = False) -> None: - try: - path_stat = path.lstat() - except FileNotFoundError: - path.mkdir(mode=0o700, parents=parents) - path_stat = path.lstat() - if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISDIR(path_stat.st_mode): - raise RuntimeError("Upload path must be a real directory") - os.chmod(path, 0o700) +def is_protected_gateway_path(path: Path) -> bool: + protected_roots = [ + DEFAULT_CODEX_HOME, + DEFAULT_SWITCHER_HOME, + ] + return any(path_is_within(path, root.expanduser().resolve()) for root in protected_roots) -def configured_download_roots() -> list[Path]: - roots = [DEFAULT_UPLOADS_DIR] - configured = os.environ.get("CODEPILOT_FILE_DOWNLOAD_ROOTS", "") - roots.extend(Path(value).expanduser() for value in configured.split(os.pathsep) if value.strip()) - return [root.resolve(strict=False) for root in roots] +def is_image_attachment(path: Path, mime_type: str) -> bool: + if mime_type.startswith("image/"): + return True + return path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} -def resolve_requested_file_path(raw_path: str, allowed_roots: list[Path] | None = None) -> Path: +def resolve_requested_file_path(raw_path: str, allowed_base: Path | None = None) -> Path: value = str(raw_path or "").strip() if not value: raise ValueError("Missing file path") @@ -875,6 +813,17 @@ def resolve_requested_file_path(raw_path: str, allowed_roots: list[Path] | None if not resolved.is_file(): raise LookupError(f"Not a file: {resolved}") + if is_protected_gateway_path(resolved): + raise PermissionError("Protected CodePilot files cannot be downloaded through the gateway") + if allowed_base is not None: + try: + resolved_base = allowed_base.expanduser().resolve(strict=True) + except FileNotFoundError as exc: + raise LookupError(f"Workspace not found: {allowed_base}") from exc + if not resolved_base.is_dir(): + raise LookupError(f"Workspace is not a directory: {resolved_base}") + if not path_is_within(resolved, resolved_base): + raise PermissionError("Only files inside the thread workspace can be downloaded through the gateway") roots = configured_download_roots() if allowed_roots is None else allowed_roots normalized_roots = [root.expanduser().resolve(strict=False) for root in roots] @@ -2206,7 +2155,6 @@ def public_health(self) -> dict: "version": read_marker(DEFAULT_SWITCHER_HOME / "gateway-version", "dev"), }, "accounts": { - "active": self.active_account_name(), "auth": self.app_server_auth_status(), }, "turns": { @@ -4500,11 +4448,36 @@ def save_attachments(self, thread_id: str, job_id: str, raw_attachments: list) - except OSError: pass try: - upload_dir.rmdir() - thread_upload_dir.rmdir() - except OSError: - pass - raise + data = base64.b64decode(encoded, validate=True) + except Exception as exc: + raise ValueError("Attachment is not valid base64") from exc + + size = len(data) + if size > MAX_ATTACHMENT_BYTES: + raise ValueError("Attachment is too large") + total_size += size + if total_size > MAX_TOTAL_ATTACHMENT_BYTES: + raise ValueError("Total attachment size is too large") + + upload_dir.mkdir(parents=True, exist_ok=True) + os.chmod(upload_dir, 0o700) + filename = safe_filename(str(attachment.get("filename", "")), f"attachment-{index}") + path = upload_dir / filename + if path.exists(): + stem = path.stem or "attachment" + suffix = path.suffix + path = upload_dir / f"{stem}-{index}{suffix}" + path.write_bytes(data) + os.chmod(path, 0o600) + + mime_type = str(attachment.get("mimeType") or "application/octet-stream") + saved.append({ + "filename": path.name, + "mimeType": mime_type, + "size": size, + "path": path, + "isImage": is_image_attachment(path, mime_type), + }) return saved @@ -4849,7 +4822,16 @@ def do_GET(self): 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] - file_path = resolve_requested_file_path(requested_path) + thread_id = (query.get("threadId") or [""])[0].strip() + if not thread_id: + raise PermissionError("Thread id is required to download files") + thread = self.state().get_thread(thread_id) + if not thread: + raise LookupError("Thread not found") + workspace = str(thread.get("cwd") or "").strip() + if not workspace: + raise PermissionError("Thread workspace is required to download files") + file_path = resolve_requested_file_path(requested_path, Path(workspace)) if parts[2] == "metadata": json_response(self, 200, {"file": file_metadata(file_path)}) return @@ -4937,6 +4919,8 @@ def do_GET(self): ) except LookupError as exc: json_error(self, 404, "gateway_unavailable", str(exc), "Refresh the app and try again.") + except PermissionError as exc: + json_error(self, 403, "protected_file", str(exc), "Choose a project file instead of CodePilot credential or state files.") except Exception as exc: json_error(self, 500, "gateway_unavailable", str(exc), "Restart CodePilot Gateway if the problem continues.") diff --git a/gateway/remote_desktop_gateway.py b/gateway/remote_desktop_gateway.py index 99297c3e..99e896e1 100644 --- a/gateway/remote_desktop_gateway.py +++ b/gateway/remote_desktop_gateway.py @@ -233,7 +233,8 @@ def _handle( return 200, status if method == "GET" and parts == ["frame"]: - response = self.host_client.call("frame.capture", {}) + session_id = _validate_identifier((query.get("sessionId") or [""])[0]) + response = self.host_client.call("frame.capture", {"sessionId": session_id}) status = int(response.get("status") or 500) error_code = response.get("errorCode") if status >= 400 or error_code: diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 3ae0ecd0..8e11fbd9 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -161,13 +161,17 @@ def test_gateway_bind_host_recognizes_only_loopback_addresses(self): self.assertFalse(is_loopback_host(host)) def setUp(self): + self._original_codex_home = gateway.DEFAULT_CODEX_HOME self._original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + self._original_uploads_dir = gateway.DEFAULT_UPLOADS_DIR 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_CODEX_HOME = self._original_codex_home gateway.DEFAULT_SWITCHER_HOME = self._original_switcher_home + gateway.DEFAULT_UPLOADS_DIR = self._original_uploads_dir self._switcher_home_tempdir.cleanup() def test_codex_child_env_prepends_homebrew_path_and_sets_codex_home(self): @@ -660,6 +664,47 @@ def test_thread_start_maps_minimal_reasoning_effort_to_medium(self): self.assertEqual(start["method"], "thread/start") self.assertEqual(start["params"]["config"], {"model_reasoning_effort": "medium"}) + def test_turn_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": "turn-id", "result": {"turn": {"id": "turn-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", "turn-id"]).__next__, + ) + + client.start() + client.turn_start("thread-1", "Hello") + sent = [json.loads(line) for line in process.stdin.getvalue().splitlines()] + turn = sent[2] + + self.assertEqual(turn["params"]["approvalPolicy"], "on-request") + self.assertEqual(turn["params"]["sandboxPolicy"], {"type": "workspaceWrite"}) + + def test_turn_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": "turn-id", "result": {"turn": {"id": "turn-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", "turn-id"]).__next__, + allow_dangerous=True, + ) + + client.start() + client.turn_start("thread-1", "Hello") + sent = [json.loads(line) for line in process.stdin.getvalue().splitlines()] + turn = sent[2] + + self.assertEqual(turn["params"]["approvalPolicy"], "never") + self.assertEqual(turn["params"]["sandboxPolicy"], {"type": "dangerFullAccess"}) + def test_thread_resume_sends_thread_id(self): process = FakeAppServerProcess([ json.dumps({"id": "init-id", "result": {"userAgent": "ua", "codexHome": "/tmp/codex", "platformFamily": "unix", "platformOs": "macos"}}) + "\n", @@ -1250,7 +1295,8 @@ def test_public_health_exposes_safe_gateway_status(self): gateway.DEFAULT_SWITCHER_HOME = original_switcher_home self.assertTrue(health["gateway"]["running"]) - self.assertEqual(health["accounts"]["active"], "main") + self.assertNotIn("active", health["accounts"]) + self.assertNotIn("main", json.dumps(health)) self.assertIn("auth", health["accounts"]) self.assertIn("notifications", health) self.assertIn("remoteDesktop", health) @@ -1299,21 +1345,40 @@ def test_resolve_requested_file_path_accepts_absolute_file_paths(self): file_path = Path(tmp) / "created-by-codex.txt" file_path.write_text("hello", encoding="utf-8") - resolved = gateway.resolve_requested_file_path(f"{file_path}:12", allowed_roots=[Path(tmp)]) + resolved = gateway.resolve_requested_file_path(f"{file_path}:12", Path(tmp)) self.assertEqual(resolved, file_path.resolve()) + def test_resolve_requested_file_path_rejects_files_outside_workspace(self): + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) / "workspace" + workspace.mkdir() + private_file = Path(tmp) / "private.txt" + private_file.write_text("secret", encoding="utf-8") + + with self.assertRaises(PermissionError): + gateway.resolve_requested_file_path(str(private_file), workspace) + def test_resolve_requested_file_path_rejects_relative_paths(self): with self.assertRaises(ValueError): gateway.resolve_requested_file_path("created-by-codex.txt") - def test_resolve_requested_file_path_rejects_files_outside_allowed_roots(self): - with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as other: - file_path = Path(other) / "private.txt" - file_path.write_text("private", encoding="utf-8") - - with self.assertRaisesRegex(PermissionError, "download roots"): - gateway.resolve_requested_file_path(str(file_path), allowed_roots=[Path(tmp)]) + def test_resolve_requested_file_path_rejects_codex_and_switcher_state(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + gateway.DEFAULT_CODEX_HOME = root / "codex" + gateway.DEFAULT_SWITCHER_HOME = root / "switcher" + gateway.DEFAULT_CODEX_HOME.mkdir() + gateway.DEFAULT_SWITCHER_HOME.mkdir() + codex_auth = gateway.DEFAULT_CODEX_HOME / "auth.json" + gateway_token = gateway.DEFAULT_SWITCHER_HOME / "phone-gateway-token" + codex_auth.write_text("{}", encoding="utf-8") + gateway_token.write_text("secret", encoding="utf-8") + + with self.assertRaises(PermissionError): + gateway.resolve_requested_file_path(str(codex_auth)) + with self.assertRaises(PermissionError): + gateway.resolve_requested_file_path(str(gateway_token)) def test_file_metadata_reports_download_details(self): with tempfile.TemporaryDirectory() as tmp: @@ -1327,6 +1392,23 @@ def test_file_metadata_reports_download_details(self): self.assertEqual(metadata["mimeType"], "text/plain") self.assertEqual(metadata["size"], 5) + def test_save_attachments_uses_private_file_permissions(self): + with tempfile.TemporaryDirectory() as tmp: + switcher_home = Path(tmp) / "switcher" + gateway.DEFAULT_SWITCHER_HOME = switcher_home + gateway.DEFAULT_UPLOADS_DIR = switcher_home / "phone-uploads" + state = GatewayState(Path(tmp) / "codex", "token", Path("/missing-codex"), False) + + saved = state.save_attachments( + "thread-1", + "job-1", + [{"filename": "note.txt", "mimeType": "text/plain", "dataBase64": "aGVsbG8="}], + ) + + upload_path = saved[0]["path"] + self.assertEqual(upload_path.stat().st_mode & 0o777, 0o600) + self.assertEqual(upload_path.parent.stat().st_mode & 0o777, 0o700) + def test_local_web_session_rejects_non_loopback_url(self): state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) diff --git a/gateway/test_remote_desktop_gateway.py b/gateway/test_remote_desktop_gateway.py index a45d3563..9cd02257 100644 --- a/gateway/test_remote_desktop_gateway.py +++ b/gateway/test_remote_desktop_gateway.py @@ -371,11 +371,20 @@ def test_frame_returns_raw_jpeg_payload(self): } gateway = RemoteDesktopGateway(host_client=host, remote_control_enabled=True) - status, payload = gateway.handle("GET", ["frame"], {}, None) + status, payload = gateway.handle("GET", ["frame"], {"sessionId": ["lease-1"]}, None) self.assertEqual(status, 200) self.assertEqual(payload["_raw"], b"\xff\xd8jpeg\xff\xd9") self.assertEqual(payload["_content_type"], "image/jpeg") + self.assertEqual(host.calls[-1], ("frame.capture", {"sessionId": "lease-1"})) + + def test_frame_requires_session_id(self): + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient()) + + status, payload = gateway.handle("GET", ["frame"], {}, None) + + self.assertEqual(status, 400) + self.assertEqual(payload, {"error": "invalid_identifier"}) def test_input_forwards_validated_event_to_native_host(self): host = FakeNativeHostClient() diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index 5cd14330..04b67cc1 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -31,6 +31,8 @@ struct CodexPhoneApp: App { switch arguments[index] { case "--gateway-url" where arguments.indices.contains(index + 1): UserDefaults.standard.set(arguments[index + 1], forKey: "gatewayURL") + case "--gateway-token" where arguments.indices.contains(index + 1): + UserDefaults.standard.set(arguments[index + 1], forKey: "gatewayToken") default: break } @@ -71,7 +73,7 @@ final class CodexPhoneAppDelegate: NSObject, UIApplicationDelegate, UNUserNotifi private func sendDeviceTokenToGateway(_ deviceToken: Data) { let gatewayURL = UserDefaults.standard.string(forKey: "gatewayURL") ?? "" - let gatewayToken = SecureGatewayTokenStore.read() + let gatewayToken = UserDefaults.standard.string(forKey: "gatewayToken") ?? "" guard !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let root = GatewayEndpoint.baseURL(from: gatewayURL), let url = URL(string: "/api/notifications/device", relativeTo: root)?.absoluteURL else { @@ -111,8 +113,9 @@ final class CodexPhoneAppDelegate: NSObject, UIApplicationDelegate, UNUserNotifi struct RootView: View { @StateObject private var model = CodexPhoneModel() - @StateObject private var credentials = GatewayCredentials() @Environment(\.scenePhase) private var scenePhase + @AppStorage("gatewayURL") private var gatewayURL = "" + @AppStorage("gatewayToken") private var gatewayToken = "" @AppStorage("totalCreditLiveActivityEnabled") private var totalCreditLiveActivityEnabled = false @State private var showingSettings = false @State private var showingStatus = false @@ -126,11 +129,11 @@ struct RootView: View { NavigationStack { Group { if gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - EmptySettingsView(gatewayURL: gatewayURLBinding, gatewayToken: gatewayTokenBinding) + EmptySettingsView(gatewayURL: $gatewayURL, gatewayToken: $gatewayToken) } else if model.threads.isEmpty && model.isLoading { ProgressView("Loading") } else { - ThreadListView(model: model, gatewayURL: gatewayURL, gatewayToken: gatewayToken) { + ThreadListView(model: model) { showingRemoteDesktop = true } } @@ -208,8 +211,8 @@ struct RootView: View { } .sheet(isPresented: $showingSettings) { SettingsView( - gatewayURL: gatewayURLBinding, - gatewayToken: gatewayTokenBinding, + gatewayURL: $gatewayURL, + gatewayToken: $gatewayToken, model: model, liveActivityError: $liveActivityError ) @@ -237,7 +240,7 @@ struct RootView: View { .presentationDetents([.medium, .large]) } .navigationDestination(item: $openedThread) { thread in - ChatView(model: model, thread: thread, gatewayURL: gatewayURL, gatewayToken: gatewayToken) + ChatView(model: model, thread: thread) } .alert("CodePilot", isPresented: .constant(model.errorMessage != nil)) { Button("OK") { model.errorMessage = nil } @@ -603,9 +606,9 @@ struct ReasoningLevelPicker: View { struct ThreadListView: View { @ObservedObject var model: CodexPhoneModel - let gatewayURL: String - let gatewayToken: String var onRemoteDesktop: () -> Void = {} + @AppStorage("gatewayURL") private var gatewayURL = "" + @AppStorage("gatewayToken") private var gatewayToken = "" @State private var renamingThread: CodexThread? @State private var renameText = "" @State private var deletingThread: CodexThread? @@ -713,7 +716,7 @@ struct ThreadListView: View { private func threadRow(_ thread: CodexThread) -> some View { NavigationLink { - ChatView(model: model, thread: thread, gatewayURL: gatewayURL, gatewayToken: gatewayToken) + ChatView(model: model, thread: thread) .id(thread.id) } label: { VStack(alignment: .leading, spacing: 6) { @@ -966,9 +969,9 @@ struct NewThreadView: View { struct ChatView: View { @ObservedObject var model: CodexPhoneModel let thread: CodexThread - let gatewayURL: String - let gatewayToken: String @Environment(\.scenePhase) private var scenePhase + @AppStorage("gatewayURL") private var gatewayURL = "" + @AppStorage("gatewayToken") private var gatewayToken = "" @State private var prompt = "" @State private var attachments: [PendingAttachment] = [] @State private var showingFileImporter = false @@ -4879,7 +4882,7 @@ struct CodexGatewayClient { } func downloadRemoteFile(path: String, baseURL: String, token: String) async throws -> URL { - guard let root = GatewayEndpoint.baseURL(from: baseURL) else { + guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw GatewayError.invalidURL } guard var components = URLComponents(url: root, resolvingAgainstBaseURL: false) else { diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift index ad11ffff..1b11487e 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift @@ -47,6 +47,15 @@ struct RemoteDesktopAPI { ) } + func issueNonce(deviceID: String) async throws -> String { + let response: RemoteDesktopNonceResponse = try await request( + "POST", + path: "/api/remote/sessions/nonce", + body: ["deviceId": deviceID] + ) + return response.nonce + } + func startSession(deviceID: String, nonce: String, signature: Data) async throws -> RemoteDesktopLease { try await request( "POST", @@ -81,13 +90,16 @@ struct RemoteDesktopAPI { return response.signals ?? [] } - func frame() async throws -> Data { + func frame(sessionID: String) 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))] + components.queryItems = [ + URLQueryItem(name: "sessionId", value: sessionID), + URLQueryItem(name: "t", value: String(Date().timeIntervalSince1970)) + ] guard let url = components.url else { throw RemoteDesktopAPIError.invalidURL } @@ -186,6 +198,10 @@ private struct RemoteSignalAcknowledgement: Decodable { let signals: [RemotePeerSignal]? } +private struct RemoteDesktopNonceResponse: Decodable { + let nonce: String +} + struct RemoteDesktopHostStatus: Codable, Equatable { let ok: Bool? let screenRecordingGranted: Bool? diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift index 91e6770d..7d5015b6 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift @@ -7,16 +7,18 @@ struct RemoteDesktopView: View { case pan } + let identity: SoftwareRemoteDeviceIdentity let gatewayURL: String let gatewayToken: String @Environment(\.dismiss) private var dismiss @FocusState private var keyboardFocused: Bool @StateObject private var peer = RemotePeerConnection() - @State private var session = RemoteDesktopSessionState(leaseID: "preview") + @State private var session: RemoteDesktopSessionState? @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 sessionTask: Task? @State private var frameTask: Task? @State private var webRTCTask: Task? @State private var inputTask: Task? @@ -139,7 +141,7 @@ struct RemoteDesktopView: View { interactionMode = interactionMode == .control ? .pan : .control }, onDisconnect: { - session.disconnect() + session?.disconnect() peer.disconnect() dismiss() } @@ -159,23 +161,21 @@ struct RemoteDesktopView: View { } .ignoresSafeArea(edges: .bottom) .onAppear { - mapper = RemoteInputMapper(sessionID: UUID().uuidString) - peer.connect() - session.connected() - startFrameLoop() - startWebRTC() + startRemoteSession() } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in - session.enterBackground() + session?.enterBackground() peer.suspend() stopFrameLoop() } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in - session.enterForeground() - if !session.shouldRequireNewLease() { + session?.enterForeground() + if session?.shouldRequireNewLease() == false, let leaseID = session?.leaseID { peer.connect() - startFrameLoop() - startWebRTC() + startFrameLoop(sessionID: leaseID) + startWebRTC(sessionID: leaseID) + } else { + startRemoteSession() } } .onReceive(peer.$videoTrack) { track in @@ -188,6 +188,8 @@ struct RemoteDesktopView: View { viewport.cursor = cursor } .onDisappear { + sessionTask?.cancel() + sessionTask = nil stopFrameLoop() stopWebRTC() inputTask?.cancel() @@ -206,14 +208,46 @@ struct RemoteDesktopView: View { } } - private func startFrameLoop() { + private func startRemoteSession() { + sessionTask?.cancel() stopFrameLoop() - guard let baseURL = GatewayEndpoint.baseURL(from: gatewayURL), - !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + stopWebRTC() + guard let api = remoteDesktopAPI() else { + frameError = "Gateway is not configured." + return + } + frameError = nil + peer.connect() + sessionTask = Task { + do { + let nonce = try await api.issueNonce(deviceID: identity.deviceID) + let signature = try identity.sign(Data(nonce.utf8)) + let lease = try await api.startSession( + deviceID: identity.deviceID, + nonce: nonce, + signature: signature + ) + await MainActor.run { + session = RemoteDesktopSessionState(leaseID: lease.id) + mapper = RemoteInputMapper(sessionID: lease.id) + session?.connected() + startFrameLoop(sessionID: lease.id) + startWebRTC(sessionID: lease.id) + } + } catch { + await MainActor.run { + frameError = "Session unavailable: \(Self.errorText(error))" + } + } + } + } + + private func startFrameLoop(sessionID: String) { + stopFrameLoop() + guard let api = remoteDesktopAPI() 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 { @@ -234,12 +268,12 @@ struct RemoteDesktopView: View { } while !Task.isCancelled { do { - let data = try await api.frame() + let data = try await api.frame(sessionID: sessionID) if let image = UIImage(data: data) { await MainActor.run { frameImage = image frameError = nil - session.connected() + session?.connected() } } try await Task.sleep(nanoseconds: 140_000_000) @@ -253,15 +287,13 @@ struct RemoteDesktopView: View { } } - private func startWebRTC() { + private func startWebRTC(sessionID: String) { stopWebRTC() - guard let baseURL = GatewayEndpoint.baseURL(from: gatewayURL), - !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - let api = RemoteDesktopAPI(baseURL: baseURL, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) + guard let api = remoteDesktopAPI() else { return } webRTCTask = Task { while !Task.isCancelled { do { - try await peer.connect(api: api) + try await peer.connect(api: api, sessionID: sessionID) await MainActor.run { frameError = nil } @@ -287,13 +319,11 @@ struct RemoteDesktopView: View { } private func send(_ event: RemoteInputEvent) { - session.enqueueInput(event) + session?.enqueueInput(event) if peer.sendInput(event) { return } - guard let baseURL = GatewayEndpoint.baseURL(from: gatewayURL), - !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - let api = RemoteDesktopAPI(baseURL: baseURL, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) + guard let api = remoteDesktopAPI() else { return } let previous = inputTask inputTask = Task { _ = await previous?.result @@ -315,6 +345,14 @@ struct RemoteDesktopView: View { } } + private func remoteDesktopAPI() -> RemoteDesktopAPI? { + guard let baseURL = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return RemoteDesktopAPI(baseURL: baseURL, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) + } + private func pasteClipboard() { guard let text = UIPasteboard.general.string, !text.isEmpty else { frameError = "The iPhone clipboard has no text." @@ -407,6 +445,9 @@ struct RemoteDesktopView: View { } private var title: String { + guard let session else { + return "Remote Desktop" + } switch session.phase { case .connecting: return "Connecting" diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift index 91928bfb..23e6ef5e 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift @@ -24,7 +24,7 @@ struct RemotePairingView: View { Section { NavigationLink { - RemoteDesktopView(gatewayURL: gatewayURL, gatewayToken: gatewayToken) + RemoteDesktopView(identity: identity, gatewayURL: gatewayURL, gatewayToken: gatewayToken) } label: { Label("Start Session", systemImage: "play.rectangle") } diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift index 76701945..4a1ee66b 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePeerConnection.swift @@ -22,9 +22,9 @@ final class RemotePeerConnection: NSObject, ObservableObject, RTCPeerConnectionD private var sessionID = "" @MainActor - func connect(api: RemoteDesktopAPI) async throws { + func connect(api: RemoteDesktopAPI, sessionID: String) async throws { disconnect() - sessionID = UUID().uuidString + self.sessionID = sessionID latencyText = "connecting" let status = try await api.status() diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 32488095..7f332f8f 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -1,16 +1,31 @@ # CodePilot Security Maintenance Notice -Status reviewed: 2026-07-19. Public launch remains blocked on the maintainer-only items below; keep the documented fail-closed controls enabled. +Maintainer intervention is needed before public launch for one product-boundary decision and one repository-history cleanup. -Detailed security findings are intentionally not stored in the public repository. Send vulnerability details, private identifiers, logs, and credential-related evidence through the maintainers' private security channel. +## Historical Machine-Specific Paths -Before public launch, maintainer coordination is still required for: +- Surface: reachable commits on the public mainline contain machine-specific absolute paths that were removed from the current tree. +- Current behavior: the tracked tree passes the privacy audit, but the old values remain recoverable from Git history. +- Risk: repository history continues to disclose private environment details even though current files are clean. +- Intervention needed: coordinate a history rewrite across all affected public refs, force-update them, invalidate old clones/caches where practical, and ask collaborators to re-clone or carefully rebase. Do not paste the historical values into public issues or pull requests. -- Reviewing and resolving the open GitHub secret-scanning alert through the private security channel, including revocation or rotation where applicable. -- A confidential review before any currently disabled privileged remote-access feature is re-enabled. -- A credential and edge-access architecture review for the remotely reachable gateway. -- Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. -- Enabling repository dependency alerts and reviewing the initial results. -- Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. +## Localhost Proxy Capability URLs -Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. +- Surface: `POST /api/local-web/sessions` requires the gateway bearer token, but returned `/api/local-web//...` URLs are fetched without bearer authentication. +- Current behavior: a random URL-safe session id gates access for up to one hour and proxies only the selected loopback port. +- Risk: anyone who obtains a live session URL can proxy GET requests to that selected loopback port through a public gateway until expiry. +- Decision needed: choose the intended auth model for proxied local-web subresources. + +Validated scan outcome: + +- No live secrets, private keys, private email addresses, or machine-specific absolute paths were found in tracked source/docs during the configured privacy audit. +- Gateway file previews require a thread id and only serve files inside that thread workspace. +- Remote desktop frame capture and input injection are bound to an active trusted-device lease. +- Privacy audit passes with no tracked private identifiers or secret-looking material. +- Mainline history scanning found machine-specific absolute paths in reachable commits; no private email address or recognized live-token pattern was found. +- 2026-07-14 validation reproduced the local-web behavior through the HTTP interface: session creation required the bearer token, then the returned `/api/local-web//...` path fetched loopback content without an `Authorization` header. + +Suggested paths: + +- Require bearer auth for every local-web proxy request and update the iOS loading layer to attach the header for subresources. +- Or treat local-web session URLs as explicit bearer-equivalent capabilities, shorten their lifetime, and document that they must not be shared. diff --git a/scripts/install-phone-gateway-agent.sh b/scripts/install-phone-gateway-agent.sh index ae9ddf5a..9325b35a 100755 --- a/scripts/install-phone-gateway-agent.sh +++ b/scripts/install-phone-gateway-agent.sh @@ -96,7 +96,7 @@ plist = { if environment: plist["EnvironmentVariables"] = environment plist_path.write_bytes(plistlib.dumps(plist, sort_keys=False)) -os.chmod(plist_path, 0o600) +plist_path.chmod(0o600) PY gateway_jobs_state() { diff --git a/scripts/save-fastlane-session.sh b/scripts/save-fastlane-session.sh index 893a1196..7fc00c13 100755 --- a/scripts/save-fastlane-session.sh +++ b/scripts/save-fastlane-session.sh @@ -48,7 +48,7 @@ if not updated: out.append(line) env_path.write_text("\n".join(out) + "\n", encoding="utf-8") -os.chmod(env_path, 0o600) +env_path.chmod(0o600) PY echo "Saved FASTLANE_SESSION to $ENV_FILE" diff --git a/scripts/setup-cloudflare-remote-access.sh b/scripts/setup-cloudflare-remote-access.sh index 930e4a1c..24529ca6 100755 --- a/scripts/setup-cloudflare-remote-access.sh +++ b/scripts/setup-cloudflare-remote-access.sh @@ -16,62 +16,6 @@ GATEWAY_TOKEN_PATH="${CODEPILOT_GATEWAY_TOKEN_PATH:-$APP_DIR/phone-gateway-token mkdir -p "$APP_DIR" "$CLOUDFLARED_DIR" chmod 700 "$APP_DIR" "$CLOUDFLARED_DIR" -validate_gateway_url() { - /usr/bin/python3 - "$GATEWAY_URL" <<'PY' -import ipaddress -import sys -from urllib.parse import urlsplit - -value = sys.argv[1] -try: - parsed = urlsplit(value) - host = parsed.hostname or "" - port = parsed.port -except ValueError as error: - raise SystemExit(f"Invalid CodePilot gateway URL: {error}") - -is_loopback = host.casefold() == "localhost" -if not is_loopback: - try: - is_loopback = ipaddress.ip_address(host).is_loopback - except ValueError: - is_loopback = False - -if ( - parsed.scheme != "http" - or not is_loopback - or parsed.username is not None - or parsed.password is not None - or parsed.path not in {"", "/"} - or parsed.query - or parsed.fragment - or port is None -): - raise SystemExit("CodePilot gateway URL must be a loopback HTTP origin with an explicit port") -PY -} - -validate_tunnel_inputs() { - local hostname="$1" - local tunnel_name="$2" - /usr/bin/python3 - "$hostname" "$tunnel_name" <<'PY' -import re -import sys - -hostname = sys.argv[1] -tunnel_name = sys.argv[2] -labels = hostname.split(".") -if ( - len(hostname) > 253 - or len(labels) < 2 - or any(not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in labels) -): - raise SystemExit("Cloudflare hostname must be a valid fully qualified DNS name") -if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,62}", tunnel_name): - raise SystemExit("Cloudflare tunnel name may contain only letters, numbers, underscores, and hyphens") -PY -} - cloudflared_bin() { if command -v cloudflared >/dev/null 2>&1; then command -v cloudflared