fix(cli): improve browser control errors
parent
4228ee326c
commit
9be3394bac
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum BrowserCLI {
|
enum BrowserCLI {
|
||||||
|
|
@ -52,100 +53,110 @@ enum BrowserCLI {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
switch sub {
|
do {
|
||||||
case "status":
|
switch sub {
|
||||||
self.printResult(
|
case "status":
|
||||||
jsonOutput: jsonOutput,
|
self.printResult(
|
||||||
res: try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/")))
|
jsonOutput: jsonOutput,
|
||||||
return 0
|
res: try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/")))
|
||||||
|
return 0
|
||||||
|
|
||||||
case "start":
|
case "start":
|
||||||
self.printResult(
|
self.printResult(
|
||||||
jsonOutput: jsonOutput,
|
jsonOutput: jsonOutput,
|
||||||
res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/start"), timeoutInterval: 15.0))
|
res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/start"), timeoutInterval: 15.0))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
case "stop":
|
case "stop":
|
||||||
self.printResult(
|
self.printResult(
|
||||||
jsonOutput: jsonOutput,
|
jsonOutput: jsonOutput,
|
||||||
res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/stop"), timeoutInterval: 15.0))
|
res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/stop"), timeoutInterval: 15.0))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
case "tabs":
|
case "tabs":
|
||||||
let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/tabs"), timeoutInterval: 3.0)
|
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 {
|
if jsonOutput {
|
||||||
self.printJSON(ok: true, result: res)
|
self.printJSON(ok: false, result: ["error": msg])
|
||||||
} else {
|
} else {
|
||||||
self.printTabs(res: res)
|
fputs("\(msg)\n", stderr)
|
||||||
}
|
}
|
||||||
return 0
|
return 1
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,7 +200,12 @@ enum BrowserCLI {
|
||||||
req.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
|
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
|
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
|
||||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
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]) {
|
private static func printResult(jsonOutput: Bool, res: [String: Any]) {
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
self.printJSON(ok: true, result: res)
|
self.printJSON(ok: true, result: res)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,16 @@ struct ClawdisCLI {
|
||||||
self.printVersion()
|
self.printVersion()
|
||||||
exit(0)
|
exit(0)
|
||||||
} catch {
|
} 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)
|
exit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)* 🦞
|
*Next: [Group Chats](./group-messages.md)* 🦞
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ Defaults:
|
||||||
- enabled: `true`
|
- enabled: `true`
|
||||||
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
||||||
- profile color: `#FF4500` (lobster-orange)
|
- profile color: `#FF4500` (lobster-orange)
|
||||||
|
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -28,6 +28,52 @@ export type ScreenshotResult = {
|
||||||
url: string;
|
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>(
|
async function fetchJson<T>(
|
||||||
url: string,
|
url: string,
|
||||||
init?: RequestInit & { timeoutMs?: number },
|
init?: RequestInit & { timeoutMs?: number },
|
||||||
|
|
@ -35,8 +81,14 @@ async function fetchJson<T>(
|
||||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
const res = await fetch(url, { ...init, signal: ctrl.signal } as RequestInit);
|
let res: Response;
|
||||||
clearTimeout(t);
|
try {
|
||||||
|
res = await fetch(url, { ...init, signal: ctrl.signal } as RequestInit);
|
||||||
|
} catch (err) {
|
||||||
|
throw enhanceBrowserFetchError(url, err, timeoutMs);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
|
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue