fix(mac): serve webchat locally in remote mode

This commit is contained in:
Peter Steinberger
2025-12-12 18:41:38 +00:00
parent 241cf10bdb
commit 7d37195c1a
4 changed files with 128 additions and 199 deletions

View File

@@ -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()
})

View File

@@ -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<Void, Never>?
private var tunnelRestartEnabled = false
private var bootWatchTask: Task<Void, Never>?
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
}
}

View File

@@ -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:<port>` over SSH (`ssh -L <ephemeral>:127.0.0.1:<port>`), then loads `/webchat/?session=<key>` 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 gateways 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

View File

@@ -5,7 +5,10 @@ read_when:
---
# Web Chat (macOS app)
The macOS menu bar app opens the gateways 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 gateways 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 gateways 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