diff --git a/apps/macos/Sources/Clawdis/WebChatServer.swift b/apps/macos/Sources/Clawdis/WebChatServer.swift index c86d25a5c..be22dca26 100644 --- a/apps/macos/Sources/Clawdis/WebChatServer.swift +++ b/apps/macos/Sources/Clawdis/WebChatServer.swift @@ -15,7 +15,7 @@ final class WebChatServer: @unchecked Sendable { private var port: NWEndpoint.Port? /// Start the local HTTP server if it isn't already running. Safe to call multiple times. - func start(root: URL) { + func start(root: URL, preferredPort: UInt16? = nil) { self.queue.async { webChatServerLogger.debug("WebChatServer start requested root=\(root.path, privacy: .public)") if self.listener != nil { return } @@ -23,8 +23,9 @@ final class WebChatServer: @unchecked Sendable { let params = NWParameters.tcp params.allowLocalEndpointReuse = true params.requiredInterfaceType = .loopback + let prefer = preferredPort.flatMap { NWEndpoint.Port(rawValue: $0) } do { - let listener = try NWListener(using: params, on: .any) + let listener = try NWListener(using: params, on: prefer ?? .any) listener.stateUpdateHandler = { [weak self] state in switch state { case .ready: @@ -44,8 +45,38 @@ final class WebChatServer: @unchecked Sendable { listener.start(queue: self.queue) self.listener = listener } catch { - webChatServerLogger - .error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)") + if let prefer { + do { + let listener = try NWListener(using: params, on: .any) + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.port = listener.port + webChatServerLogger.debug( + "WebChatServer ready on 127.0.0.1:\(listener.port?.rawValue ?? 0)") + case let .failed(error): + webChatServerLogger + .error("WebChatServer failed: \(error.localizedDescription, privacy: .public)") + self?.listener = nil + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection: connection) + } + listener.start(queue: self.queue) + self.listener = listener + webChatServerLogger.debug( + "WebChatServer fell back to ephemeral port (preferred \(prefer.rawValue))") + } catch { + webChatServerLogger + .error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)") + } + } else { + webChatServerLogger + .error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)") + } } } } @@ -112,8 +143,16 @@ final class WebChatServer: @unchecked Sendable { } webChatServerLogger.debug("WebChatServer request line=\(requestLine, privacy: .public)") let parts = requestLine.split(separator: " ") - guard parts.count >= 2, parts[0] == "GET" else { - webChatServerLogger.error("WebChatServer non-GET request: \(requestLine, privacy: .public)") + guard parts.count >= 2 else { + webChatServerLogger.error("WebChatServer invalid request: \(requestLine, privacy: .public)") + connection.cancel() + return + } + let method = parts[0] + let includeBody = method == "GET" + guard includeBody || method == "HEAD" else { + webChatServerLogger.error( + "WebChatServer unsupported request method: \(requestLine, privacy: .public)") connection.cancel() return } @@ -137,25 +176,53 @@ final class WebChatServer: @unchecked Sendable { webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)") // Simple directory traversal guard: served files must live under the bundled web root. guard fileURL.path.hasPrefix(root.path) else { - self.send(status: 403, mime: "text/plain", body: Data("Forbidden".utf8), over: connection) + let forbidden = Data("Forbidden".utf8) + self.send( + status: 403, + mime: "text/plain", + body: forbidden, + contentLength: forbidden.count, + includeBody: includeBody, + over: connection) return } guard let data = try? Data(contentsOf: fileURL) else { webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)") - self.send(status: 404, mime: "text/plain", body: Data("Not Found".utf8), over: connection) + self.send( + status: 404, + mime: "text/plain", + body: Data("Not Found".utf8), + contentLength: "Not Found".utf8.count, + includeBody: includeBody, + over: connection) return } let mime = self.mimeType(forExtension: fileURL.pathExtension) - self.send(status: 200, mime: mime, body: data, over: connection) + self.send( + status: 200, + mime: mime, + body: data, + contentLength: data.count, + includeBody: includeBody, + over: connection) } - private func send(status: Int, mime: String, body: Data, over connection: NWConnection) { + private func send( + status: Int, + mime: String, + body: Data, + contentLength: Int, + includeBody: Bool, + over connection: NWConnection) + { let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" + - "Content-Length: \(body.count)\r\n" + + "Content-Length: \(contentLength)\r\n" + "Content-Type: \(mime)\r\n" + "Connection: close\r\n\r\n" var response = Data(headers.utf8) - response.append(body) + if includeBody { + response.append(body) + } connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 3e0626cf6..79a90ffe3 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -1,6 +1,5 @@ import AppKit import Foundation -import Network import OSLog import WebKit @@ -32,12 +31,10 @@ enum WebChatPresentation { final class WebChatWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate { private let webView: WKWebView private let sessionKey: String - private var tunnel: WebChatTunnel? private var baseEndpoint: URL? private let remotePort: Int private var resolvedGatewayPort: Int? private var reachabilityTask: Task? - private var tunnelRestartEnabled = false private var bootWatchTask: Task? let presentation: WebChatPresentation var onPanelClosed: (() -> Void)? @@ -78,7 +75,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N @MainActor deinit { self.reachabilityTask?.cancel() self.bootWatchTask?.cancel() - self.stopTunnel(allowRestart: false) self.removeDismissMonitor() self.removePanelObservers() } @@ -232,10 +228,21 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N private func prepareEndpoint(remotePort: Int) async throws -> URL { if CommandResolver.connectionModeIsRemote() { - try await self.startOrRestartTunnel() - } else { - URL(string: "http://127.0.0.1:\(remotePort)/")! + let root = try Self.webChatAssetsRootURL() + WebChatServer.shared.start(root: root, preferredPort: nil) + let deadline = Date().addingTimeInterval(2.0) + while Date() < deadline { + if let url = WebChatServer.shared.baseURL() { + return url + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + throw NSError( + domain: "WebChat", + code: 11, + userInfo: [NSLocalizedDescriptionKey: "webchat server did not start"]) } + return URL(string: "http://127.0.0.1:\(remotePort)/")! } private func loadWebChat(baseEndpoint: URL) { @@ -311,42 +318,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N } } - private func startOrRestartTunnel() async throws -> URL { - // Kill existing tunnel if any - self.stopTunnel(allowRestart: false) - - let tunnel = try await WebChatTunnel.create(remotePort: self.remotePort, preferredLocalPort: 18788) - self.tunnel = tunnel - self.tunnelRestartEnabled = true - - // Auto-restart on unexpected termination while window lives - tunnel.process.terminationHandler = { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - guard self.tunnelRestartEnabled else { return } - webChatLogger.error("webchat tunnel terminated; restarting") - do { - // Recreate the tunnel silently so the window keeps working without user intervention. - let base = try await self.startOrRestartTunnel() - self.loadPage(baseURL: base) - } catch { - self.showError(error.localizedDescription) - } - } - } - - guard let port = tunnel.localPort else { - throw NSError(domain: "WebChat", code: 2, userInfo: [NSLocalizedDescriptionKey: "tunnel missing port"]) - } - return URL(string: "http://127.0.0.1:\(port)/")! - } - - private func stopTunnel(allowRestart: Bool) { - self.tunnelRestartEnabled = allowRestart - self.tunnel?.terminate() - self.tunnel = nil - } - func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) { guard case .panel = self.presentation, let window else { return } self.panelCloseNotified = false @@ -410,7 +381,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N func shutdown() { self.reachabilityTask?.cancel() self.bootWatchTask?.cancel() - self.stopTunnel(allowRestart: false) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -502,6 +472,17 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N } self.observers.removeAll() } + + fileprivate static func webChatAssetsRootURL() throws -> URL { + if let url = Bundle.main.url(forResource: "WebChat", withExtension: nil) { return url } + if let url = Bundle.main.resourceURL?.appendingPathComponent("WebChat"), + FileManager.default.fileExists(atPath: url.path) + { + return url + } + if let url = Bundle.module.url(forResource: "WebChat", withExtension: nil) { return url } + throw NSError(domain: "WebChat", code: 10, userInfo: [NSLocalizedDescriptionKey: "WebChat assets missing"]) + } } extension WebChatWindowController { @@ -548,7 +529,6 @@ final class WebChatManager { private var swiftWindowController: WebChatSwiftUIWindowController? private var swiftPanelController: WebChatSwiftUIWindowController? private var swiftPanelSessionKey: String? - private var browserTunnel: WebChatTunnel? var onPanelVisibilityChanged: ((Bool) -> Void)? func show(sessionKey: String) { @@ -671,8 +651,6 @@ final class WebChatManager { @MainActor func resetTunnels() { - self.browserTunnel?.terminate() - self.browserTunnel = nil self.windowController?.shutdown() self.windowController?.close() self.windowController = nil @@ -689,31 +667,37 @@ final class WebChatManager { @MainActor func openInBrowser(sessionKey: String) async { - let port = AppStateStore.webChatPort let base: URL let gatewayPort: Int if CommandResolver.connectionModeIsRemote() { do { - // Prefer the configured port; fall back if busy. - let tunnel = try await WebChatTunnel.create( - remotePort: port, - preferredLocalPort: UInt16(port)) let forwarded = try await RemoteTunnelManager.shared.ensureControlTunnel() gatewayPort = Int(forwarded) - self.browserTunnel?.terminate() - self.browserTunnel = tunnel - guard let local = tunnel.localPort else { + + let root = try WebChatWindowController.webChatAssetsRootURL() + WebChatServer.shared.start(root: root, preferredPort: nil) + let deadline = Date().addingTimeInterval(2.0) + var resolved: URL? + while Date() < deadline { + if let url = WebChatServer.shared.baseURL() { + resolved = url + break + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + guard let resolved else { throw NSError( domain: "WebChat", - code: 7, - userInfo: [NSLocalizedDescriptionKey: "Tunnel missing local port"]) + code: 11, + userInfo: [NSLocalizedDescriptionKey: "webchat server did not start"]) } - base = URL(string: "http://127.0.0.1:\(local)/")! + base = resolved } catch { NSAlert(error: error).runModal() return } } else { + let port = AppStateStore.webChatPort gatewayPort = GatewayEnvironment.gatewayPort() base = URL(string: "http://127.0.0.1:\(port)/")! } @@ -751,128 +735,3 @@ final class WebChatManager { // Keep panel controllers cached so reopening doesn't re-bootstrap. } } - -// MARK: - Port forwarding tunnel - -final class WebChatTunnel { - let process: Process - let localPort: UInt16? - - private init(process: Process, localPort: UInt16?) { - self.process = process - self.localPort = localPort - } - - deinit { - let pid = self.process.processIdentifier - self.process.terminate() - Task { await PortGuardian.shared.removeRecord(pid: pid) } - } - - func terminate() { - let pid = self.process.processIdentifier - if self.process.isRunning { - self.process.terminate() - self.process.waitUntilExit() - } - Task { await PortGuardian.shared.removeRecord(pid: pid) } - } - - static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> WebChatTunnel { - let settings = CommandResolver.connectionSettings() - guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { - throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"]) - } - - let localPort = try await Self.findPort(preferred: preferredLocalPort) - var args: [String] = [ - "-o", "BatchMode=yes", - "-o", "IdentitiesOnly=yes", - "-o", "ExitOnForwardFailure=yes", - "-o", "ServerAliveInterval=15", - "-o", "ServerAliveCountMax=3", - "-o", "TCPKeepAlive=yes", - "-N", - "-L", "\(localPort):127.0.0.1:\(remotePort)", - ] - if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } - let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) - if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) } - let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host - args.append(userHost) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = args - let pipe = Pipe() - process.standardError = pipe - // Consume stderr so ssh cannot block if it logs - pipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty, - let line = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), - !line.isEmpty else { return } - webChatLogger.error("webchat tunnel stderr: \(line, privacy: .public)") - } - try process.run() - // Track tunnel so we can clean up stale listeners on restart. - Task { - await PortGuardian.shared.record( - port: Int(localPort), - pid: process.processIdentifier, - command: process.executableURL?.path ?? "ssh", - mode: CommandResolver.connectionSettings().mode) - } - - return WebChatTunnel(process: process, localPort: localPort) - } - - private static func findPort(preferred: UInt16?) async throws -> UInt16 { - if let preferred, portIsFree(preferred) { return preferred } - - return try await withCheckedThrowingContinuation { cont in - let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility) - do { - let listener = try NWListener(using: .tcp, on: .any) - listener.newConnectionHandler = { connection in connection.cancel() } - listener.stateUpdateHandler = { state in - switch state { - case .ready: - if let port = listener.port?.rawValue { - listener.stateUpdateHandler = nil - listener.cancel() - cont.resume(returning: port) - } - case let .failed(error): - listener.stateUpdateHandler = nil - listener.cancel() - cont.resume(throwing: error) - default: - break - } - } - listener.start(queue: queue) - } catch { - cont.resume(throwing: error) - } - } - } - - private static func portIsFree(_ port: UInt16) -> Bool { - do { - let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!) - listener.cancel() - return true - } catch { - return false - } - } -} - -extension URL { - func appending(queryItems: [URLQueryItem]) -> URL { - guard var comps = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self } - comps.queryItems = (comps.queryItems ?? []) + queryItems - return comps.url ?? self - } -} diff --git a/docs/mac/remote.md b/docs/mac/remote.md index 86bb01d6f..203ea7981 100644 --- a/docs/mac/remote.md +++ b/docs/mac/remote.md @@ -28,8 +28,8 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway 4) Health checks and Web Chat will now run through this SSH tunnel automatically. ## Web Chat over SSH -- The gateway hosts a loopback-only HTTP server (default 18788, see `webchat.port`). -- The mac app forwards `127.0.0.1:` over SSH (`ssh -L :127.0.0.1:`), then loads `/webchat/?session=` in-app. Sends go in-process on the gateway (no CLI spawn/PATH issues). +- The mac app serves the WebChat assets locally (from the app bundle) and connects to the gateway over the forwarded WebSocket control port (default 18789). +- The gateway’s own loopback WebChat HTTP server (default 18788, see `webchat.port`) is not required in remote mode. - Keep the feature enabled in *Settings → Config → Web chat*. Disable it to hide the menu entry entirely. ## Permissions diff --git a/docs/mac/webchat.md b/docs/mac/webchat.md index cb84fefa4..78b6b0641 100644 --- a/docs/mac/webchat.md +++ b/docs/mac/webchat.md @@ -5,7 +5,10 @@ read_when: --- # Web Chat (macOS app) -The macOS menu bar app opens the gateway’s loopback web chat server in a WKWebView. It reuses the **primary Clawd session** (`main` by default, configurable via `inbound.reply.session.mainKey`). The server is started by the Node gateway (default port 18788, see `webchat.port`). +The macOS menu bar app embeds the WebChat UI in a WKWebView and reuses the **primary Clawd session** (`main` by default, configurable via `inbound.reply.session.mainKey`). + +- **Local mode**: loads the gateway’s loopback WebChat HTTP server (default port 18788, see `webchat.port`). +- **Remote mode**: serves the WebChat assets locally from the mac app bundle (via `WebChatServer`) and only forwards the gateway WebSocket control port over SSH. ## Launch & debugging - Manual: Lobster menu → “Open Chat”. @@ -20,7 +23,7 @@ The macOS menu bar app opens the gateway’s loopback web chat server in a WKWeb - Debug-only: a native SwiftUI “glass” chat UI (same WS transport, attachments + thinking selector) can replace the WKWebView. Enable it via Debug → “Use SwiftUI web chat (glass, gateway WS)” (default off). ## Security / surface area -- Loopback server only; remote mode uses SSH port-forwarding from the gateway host to the Mac. CSP is set to `default-src 'self' 'unsafe-inline' data: blob:`. +- Loopback server only; remote mode forwards only the gateway WebSocket control port over SSH. CSP is set to `default-src 'self' 'unsafe-inline' data: blob:`. - Web Inspector is opt-in via right-click; otherwise WKWebView stays in the app sandbox. ## Known limitations