fix(cli): improve browser control errors

This commit is contained in:
Peter Steinberger
2025-12-13 17:37:00 +00:00
parent 4228ee326c
commit 9be3394bac
6 changed files with 233 additions and 92 deletions

View File

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

View File

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

View File

@@ -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 doesnt 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)* 🦞

View File

@@ -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
{

View File

@@ -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,
);
});
});

View File

@@ -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<T>(
url: string,
init?: RequestInit & { timeoutMs?: number },
@@ -35,8 +81,14 @@ async function fetchJson<T>(
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}`);