From 9be3394bac99329813a7228433cc278504fd0ad6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 17:37:00 +0000 Subject: [PATCH] fix(cli): improve browser control errors --- .../macos/Sources/ClawdisCLI/BrowserCLI.swift | 227 +++++++++++------- .../macos/Sources/ClawdisCLI/ClawdisCLI.swift | 11 +- docs/agents.md | 6 + docs/configuration.md | 1 + src/browser/client.test.ts | 24 ++ src/browser/client.ts | 56 ++++- 6 files changed, 233 insertions(+), 92 deletions(-) create mode 100644 src/browser/client.test.ts diff --git a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift index 043f8e001..19259a8bc 100644 --- a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation enum BrowserCLI { @@ -52,100 +53,110 @@ enum BrowserCLI { ]) } - switch sub { - case "status": - self.printResult( - jsonOutput: jsonOutput, - res: try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/"))) - return 0 + do { + switch sub { + case "status": + self.printResult( + jsonOutput: jsonOutput, + res: try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/"))) + return 0 - case "start": - self.printResult( - jsonOutput: jsonOutput, - res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/start"), timeoutInterval: 15.0)) - return 0 + case "start": + self.printResult( + jsonOutput: jsonOutput, + res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/start"), timeoutInterval: 15.0)) + return 0 - case "stop": - self.printResult( - jsonOutput: jsonOutput, - res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/stop"), timeoutInterval: 15.0)) - return 0 + case "stop": + self.printResult( + jsonOutput: jsonOutput, + res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/stop"), timeoutInterval: 15.0)) + return 0 - case "tabs": - let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/tabs"), timeoutInterval: 3.0) + case "tabs": + let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/tabs"), timeoutInterval: 3.0) + if jsonOutput { + self.printJSON(ok: true, result: res) + } else { + self.printTabs(res: res) + } + return 0 + + case "open": + guard let url = rest.first, !url.isEmpty else { + self.printHelp() + return 2 + } + self.printResult( + jsonOutput: jsonOutput, + res: try await self.httpJSON( + method: "POST", + url: baseURL.appendingPathComponent("/tabs/open"), + body: ["url": url], + timeoutInterval: 15.0)) + return 0 + + case "focus": + guard let id = rest.first, !id.isEmpty else { + self.printHelp() + return 2 + } + self.printResult( + jsonOutput: jsonOutput, + res: try await self.httpJSON( + method: "POST", + url: baseURL.appendingPathComponent("/tabs/focus"), + body: ["targetId": id], + timeoutInterval: 5.0)) + return 0 + + case "close": + guard let id = rest.first, !id.isEmpty else { + self.printHelp() + return 2 + } + self.printResult( + jsonOutput: jsonOutput, + res: try await self.httpJSON( + method: "DELETE", + url: baseURL.appendingPathComponent("/tabs/\(id)"), + timeoutInterval: 5.0)) + return 0 + + case "screenshot": + var url = baseURL.appendingPathComponent("/screenshot") + var items: [URLQueryItem] = [] + if let targetId, !targetId.isEmpty { + items.append(URLQueryItem(name: "targetId", value: targetId)) + } + if fullPage { + items.append(URLQueryItem(name: "fullPage", value: "1")) + } + if !items.isEmpty { + url = self.withQuery(url, items: items) + } + let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) + if jsonOutput { + self.printJSON(ok: true, result: res) + } else if let path = res["path"] as? String, !path.isEmpty { + print("MEDIA:\(path)") + } else { + self.printResult(jsonOutput: false, res: res) + } + return 0 + + default: + self.printHelp() + return 2 + } + } catch { + let msg = self.describeError(error, baseURL: baseURL) if jsonOutput { - self.printJSON(ok: true, result: res) + self.printJSON(ok: false, result: ["error": msg]) } else { - self.printTabs(res: res) + fputs("\(msg)\n", stderr) } - return 0 - - case "open": - guard let url = rest.first, !url.isEmpty else { - self.printHelp() - return 2 - } - self.printResult( - jsonOutput: jsonOutput, - res: try await self.httpJSON( - method: "POST", - url: baseURL.appendingPathComponent("/tabs/open"), - body: ["url": url], - timeoutInterval: 15.0)) - return 0 - - case "focus": - guard let id = rest.first, !id.isEmpty else { - self.printHelp() - return 2 - } - self.printResult( - jsonOutput: jsonOutput, - res: try await self.httpJSON( - method: "POST", - url: baseURL.appendingPathComponent("/tabs/focus"), - body: ["targetId": id], - timeoutInterval: 5.0)) - return 0 - - case "close": - guard let id = rest.first, !id.isEmpty else { - self.printHelp() - return 2 - } - self.printResult( - jsonOutput: jsonOutput, - res: try await self.httpJSON( - method: "DELETE", - url: baseURL.appendingPathComponent("/tabs/\(id)"), - timeoutInterval: 5.0)) - return 0 - - case "screenshot": - var url = baseURL.appendingPathComponent("/screenshot") - var items: [URLQueryItem] = [] - if let targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if fullPage { - items.append(URLQueryItem(name: "fullPage", value: "1")) - } - if !items.isEmpty { - url = self.withQuery(url, items: items) - } - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) - if jsonOutput { - self.printJSON(ok: true, result: res) - } else if let path = res["path"] as? String, !path.isEmpty { - print("MEDIA:\(path)") - } else { - self.printResult(jsonOutput: false, res: res) - } - return 0 - - default: - self.printHelp() - return 2 + return 1 } } @@ -189,7 +200,12 @@ enum BrowserCLI { req.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) } - let (data, resp) = try await URLSession.shared.data(for: req) + let (data, resp): (Data, URLResponse) + do { + (data, resp) = try await URLSession.shared.data(for: req) + } catch { + throw self.wrapNetworkError(error, url: url, timeoutInterval: timeoutInterval) + } let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -209,6 +225,39 @@ enum BrowserCLI { ]) } + private static func describeError(_ error: Error, baseURL: URL) -> String { + let ns = error as NSError + let msg = ns.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + if !msg.isEmpty { return msg } + return "Browser request failed (\(baseURL.absoluteString))" + } + + private static func wrapNetworkError(_ error: Error, url: URL, timeoutInterval: TimeInterval) -> Error { + let ns = error as NSError + if ns.domain == NSURLErrorDomain { + // Keep this short: this often shows up inside SSH output and agent logs. + switch ns.code { + case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost, NSURLErrorTimedOut, + NSURLErrorCannotFindHost, NSURLErrorNotConnectedToInternet, NSURLErrorDNSLookupFailed: + let base = url.absoluteString + let hint = """ + Can't reach the clawd browser control server at \(base). + Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again. + """ + return NSError(domain: "BrowserCLI", code: ns.code, userInfo: [ + NSLocalizedDescriptionKey: hint, + ]) + default: + break + } + } + let base = url.absoluteString + let generic = "Failed to reach \(base) (timeout \(Int(timeoutInterval))s)." + return NSError(domain: "BrowserCLI", code: ns.code, userInfo: [ + NSLocalizedDescriptionKey: generic, + ]) + } + private static func printResult(jsonOutput: Bool, res: [String: Any]) { if jsonOutput { self.printJSON(ok: true, result: res) diff --git a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift index 14ba977fa..97e9df996 100644 --- a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift @@ -37,7 +37,16 @@ struct ClawdisCLI { self.printVersion() exit(0) } catch { - fputs("clawdis-mac error: \(error)\n", stderr) + // Keep errors readable for CLI + SSH callers; print full domains/codes only when asked. + let verbose = ProcessInfo.processInfo.environment["CLAWDIS_MAC_VERBOSE_ERRORS"] == "1" + if verbose { + fputs("clawdis-mac error: \(error)\n", stderr) + } else { + let ns = error as NSError + let message = ns.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + let desc = message.isEmpty ? String(describing: error) : message + fputs("clawdis-mac error: \(desc) (\(ns.domain), \(ns.code))\n", stderr) + } exit(2) } } diff --git a/docs/agents.md b/docs/agents.md index 3d6894d9c..1090d2cf6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -93,6 +93,12 @@ If you enable the clawd-managed browser (default on), the agent can use: This uses a dedicated Chrome/Chromium profile (lobster-orange by default) so it doesn’t interfere with your daily browser. +## Debugging `clawdis-mac` errors + +When the agent runs `clawdis-mac` (often over SSH), the CLI prints compact, human-readable errors by default. + +- To get the full `NSError` dump (domain/code/userInfo), rerun with `CLAWDIS_MAC_VERBOSE_ERRORS=1` in the environment. + --- *Next: [Group Chats](./group-messages.md)* 🦞 diff --git a/docs/configuration.md b/docs/configuration.md index 5b8c8f776..95f013762 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -111,6 +111,7 @@ Defaults: - enabled: `true` - control URL: `http://127.0.0.1:18791` (CDP uses `18792`) - profile color: `#FF4500` (lobster-orange) +- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`). ```json5 { diff --git a/src/browser/client.test.ts b/src/browser/client.test.ts new file mode 100644 index 000000000..da51cb224 --- /dev/null +++ b/src/browser/client.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { browserStatus } from "./client.js"; + +describe("browser client", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("wraps connection failures with a gateway hint", async () => { + const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), { + code: "ECONNREFUSED", + }); + const fetchFailed = Object.assign(new TypeError("fetch failed"), { + cause: refused, + }); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed)); + + await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow( + /Start .*gateway/i, + ); + }); +}); diff --git a/src/browser/client.ts b/src/browser/client.ts index 41ffa7b99..64b147031 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -28,6 +28,52 @@ export type ScreenshotResult = { url: string; }; +function unwrapCause(err: unknown): unknown { + if (!err || typeof err !== "object") return null; + const cause = (err as { cause?: unknown }).cause; + return cause ?? null; +} + +function enhanceBrowserFetchError( + url: string, + err: unknown, + timeoutMs: number, +): Error { + const cause = unwrapCause(err); + const code = + (cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code ?? "") + : "") || + (err && typeof err === "object" && "code" in err + ? String((err as { code?: unknown }).code ?? "") + : ""); + + const hint = + "Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again."; + + if (code === "ECONNREFUSED") { + return new Error( + `Can't reach the clawd browser control server at ${url} (connection refused). ${hint}`, + ); + } + if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") { + return new Error( + `Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`, + ); + } + + const msg = String(err); + if (msg.toLowerCase().includes("abort")) { + return new Error( + `Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`, + ); + } + + return new Error( + `Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`, + ); +} + async function fetchJson( url: string, init?: RequestInit & { timeoutMs?: number }, @@ -35,8 +81,14 @@ async function fetchJson( const timeoutMs = init?.timeoutMs ?? 5000; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); - const res = await fetch(url, { ...init, signal: ctrl.signal } as RequestInit); - clearTimeout(t); + let res: Response; + try { + res = await fetch(url, { ...init, signal: ctrl.signal } as RequestInit); + } catch (err) { + throw enhanceBrowserFetchError(url, err, timeoutMs); + } finally { + clearTimeout(t); + } if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);