mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix(clawhub): explain CLI transport failures (#2681)
* fix(clawhub): explain CLI transport failures * fix(clawhub): redact generic auth credentials * fix(clawhub): harden CLI error redaction * fix: align auth peer dependency --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
co-authored by
Patrick Erichsen
parent
d6e54bdb65
commit
96b04b0837
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
"dependencies": {
|
||||
"@auth/core": "0.37.4",
|
||||
"@auth/core": "0.41.1",
|
||||
"@convex-dev/auth": "0.0.94",
|
||||
"@fontsource/bricolage-grotesque": "5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@auth/core": ["@auth/core@0.37.4", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^5.9.6", "oauth4webapi": "^3.1.1", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw=="],
|
||||
"@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
@@ -1307,7 +1307,7 @@
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
@@ -1871,6 +1871,8 @@
|
||||
|
||||
"@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@convex-dev/auth/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
|
||||
"@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="],
|
||||
|
||||
"@react-email/tailwind/tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
"verify:convex-contract": "bun scripts/verify-convex-contract.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "0.37.4",
|
||||
"@auth/core": "0.41.1",
|
||||
"@convex-dev/auth": "0.0.94",
|
||||
"@fontsource/bricolage-grotesque": "5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||
|
||||
@@ -57,7 +57,7 @@ import { cmdUnstarSkill } from "./cli/commands/unstar.js";
|
||||
import { configureCommanderHelp, styleEnvBlock, styleError, styleTitle } from "./cli/helpStyle.js";
|
||||
import { DEFAULT_REGISTRY, DEFAULT_SITE } from "./cli/registry.js";
|
||||
import type { GlobalOpts } from "./cli/types.js";
|
||||
import { fail } from "./cli/ui.js";
|
||||
import { fail, formatError } from "./cli/ui.js";
|
||||
|
||||
const CLI_HELP_HEADER = styleTitle(`🦞 ClawHub CLI ${getCliBuildLabel()}`);
|
||||
const HELP_DESCRIPTION = "Display help for command";
|
||||
@@ -910,6 +910,5 @@ program.action(() => {
|
||||
validateTopLevelCommand(process.argv.slice(2));
|
||||
|
||||
void program.parseAsync(process.argv).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fail(message);
|
||||
fail(formatError(error));
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
}));
|
||||
|
||||
const { createCrabLoader, openInBrowser } = await import("./ui");
|
||||
const { createCrabLoader, formatError, openInBrowser } = await import("./ui");
|
||||
|
||||
type ErrorHandler = (error: NodeJS.ErrnoException) => void;
|
||||
|
||||
@@ -76,6 +76,125 @@ describe("openInBrowser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatError", () => {
|
||||
it("keeps ordinary errors concise", () => {
|
||||
expect(formatError(new Error("HTTP 404: skill not found"))).toBe("HTTP 404: skill not found");
|
||||
});
|
||||
|
||||
it("redacts sensitive non-Error values", () => {
|
||||
const formatted = formatError("Authorization: Token abc123");
|
||||
|
||||
expect(formatted).toBe("Authorization: [redacted]");
|
||||
});
|
||||
|
||||
it("adds DNS guidance for nested fetch failures", () => {
|
||||
const cause = Object.assign(new Error("getaddrinfo ENOTFOUND clawhub.ai"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const error = new TypeError("fetch failed", { cause });
|
||||
|
||||
const formatted = formatError(error);
|
||||
|
||||
expect(formatted).toContain("Network request failed: DNS lookup failed.");
|
||||
expect(formatted).toContain("fetch failed: getaddrinfo ENOTFOUND clawhub.ai");
|
||||
expect(formatted).toContain("HTTPS_PROXY");
|
||||
});
|
||||
|
||||
it("redacts tokens, callback URLs, and proxy credentials in transport details", () => {
|
||||
const error = new Error(
|
||||
"curl failed for https://clawhub.ai/login?token=clh_secret&redirect_uri=http://127.0.0.1:54321/callback Authorization: Bearer clh_other",
|
||||
{
|
||||
cause: new Error("proxy http://user:password@proxy.example:8080 failed"),
|
||||
},
|
||||
);
|
||||
|
||||
const formatted = formatError(error);
|
||||
|
||||
expect(formatted).toContain("Network request failed");
|
||||
expect(formatted).not.toContain("clh_secret");
|
||||
expect(formatted).not.toContain("clh_other");
|
||||
expect(formatted).not.toContain("127.0.0.1:54321");
|
||||
expect(formatted).not.toContain("user:password");
|
||||
expect(formatted).toContain("token=[redacted]");
|
||||
expect(formatted).toContain("redirect_uri=[redacted]");
|
||||
expect(formatted).toContain("Authorization: Bearer [redacted]");
|
||||
expect(formatted).toContain("http://[redacted]@proxy.example:8080");
|
||||
});
|
||||
|
||||
it("redacts token-only URL userinfo in transport details", () => {
|
||||
const error = new Error("proxy https://proxy-token@proxy.example failed");
|
||||
|
||||
const formatted = formatError(error);
|
||||
|
||||
expect(formatted).toContain("Network request failed");
|
||||
expect(formatted).not.toContain("proxy-token");
|
||||
expect(formatted).toContain("https://[redacted]@proxy.example");
|
||||
});
|
||||
|
||||
it("redacts generic Authorization schemes with separated credentials", () => {
|
||||
const error = new Error("fetch failed: Authorization: Basic dXNlcjpwYXNz");
|
||||
|
||||
const formatted = formatError(error);
|
||||
|
||||
expect(formatted).toContain("Authorization: [redacted]");
|
||||
expect(formatted).not.toContain("Basic");
|
||||
expect(formatted).not.toContain("dXNlcjpwYXNz");
|
||||
});
|
||||
|
||||
it("omits unsafe fields from structured transport causes", () => {
|
||||
const error = new TypeError("fetch failed", {
|
||||
cause: {
|
||||
code: "ENOTFOUND",
|
||||
message: "getaddrinfo ENOTFOUND clawhub.ai",
|
||||
headers: {
|
||||
Authorization: "Basic dXNlcjpwYXNz",
|
||||
Cookie: "session=secret-session",
|
||||
},
|
||||
url: "https://user:password@clawhub.ai/search?token=clh_secret",
|
||||
},
|
||||
});
|
||||
|
||||
const formatted = formatError(error);
|
||||
|
||||
expect(formatted).toContain("Network request failed: DNS lookup failed.");
|
||||
expect(formatted).toContain("message: getaddrinfo ENOTFOUND clawhub.ai");
|
||||
expect(formatted).toContain("code: ENOTFOUND");
|
||||
expect(formatted).not.toContain("headers");
|
||||
expect(formatted).not.toContain("Authorization");
|
||||
expect(formatted).not.toContain("Basic");
|
||||
expect(formatted).not.toContain("dXNlcjpwYXNz");
|
||||
expect(formatted).not.toContain("Cookie");
|
||||
expect(formatted).not.toContain("secret-session");
|
||||
expect(formatted).not.toContain("user:password");
|
||||
expect(formatted).not.toContain("clh_secret");
|
||||
});
|
||||
|
||||
it("redacts cookie and JSON-shaped credentials in transport details", () => {
|
||||
const error = new Error(
|
||||
'fetch failed: {"headers":{"Authorization":"Basic dXNlcjpwYXNz","Cookie":"session=secret-session"},"token":"clh_secret"}',
|
||||
);
|
||||
|
||||
const formatted = formatError(error);
|
||||
|
||||
expect(formatted).toContain("Network request failed");
|
||||
expect(formatted).toContain('"Authorization":"[redacted]"');
|
||||
expect(formatted).toContain('"Cookie":"[redacted]"');
|
||||
expect(formatted).toContain('"token":"[redacted]"');
|
||||
expect(formatted).not.toContain("Basic");
|
||||
expect(formatted).not.toContain("dXNlcjpwYXNz");
|
||||
expect(formatted).not.toContain("secret-session");
|
||||
expect(formatted).not.toContain("clh_secret");
|
||||
});
|
||||
|
||||
it("classifies TLS and timeout failures", () => {
|
||||
const tls = new Error("self signed certificate in certificate chain");
|
||||
const timeout = new Error("Request timed out after 30s");
|
||||
|
||||
expect(formatError(tls)).toContain("TLS or certificate validation failed");
|
||||
expect(formatError(timeout)).toContain("the request timed out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCrabLoader", () => {
|
||||
it("keeps non-interactive spinner output stable", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
@@ -137,8 +137,12 @@ export function createCrabLoader(text: string): CrabLoader {
|
||||
}
|
||||
|
||||
export function formatError(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
if (error instanceof Error) {
|
||||
const diagnostic = formatTransportError(error);
|
||||
if (diagnostic) return diagnostic;
|
||||
return redactSensitiveText(error.message);
|
||||
}
|
||||
return redactSensitiveText(String(error));
|
||||
}
|
||||
|
||||
function isStdoutColorEnabled() {
|
||||
@@ -173,3 +177,126 @@ export function fail(message: string): never {
|
||||
console.error(`${formatErrorLabel()} ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function formatTransportError(error: Error) {
|
||||
const chain = errorChain(error);
|
||||
const messages = chain
|
||||
.map((entry) => entry.message)
|
||||
.filter((message) => message.trim().length > 0);
|
||||
const codes = chain.map((entry) => entry.code).filter((code): code is string => Boolean(code));
|
||||
const combined = [...messages, ...codes].join("\n");
|
||||
if (!isTransportError(combined)) return null;
|
||||
|
||||
const sanitized = redactSensitiveText(messages.join(": "));
|
||||
const reason = classifyTransportError(combined);
|
||||
const detail = sanitized.length > 0 ? ` ${sanitized}` : "";
|
||||
return `${reason}${detail}\nCheck the registry URL, network connection, proxy settings (HTTPS_PROXY, HTTP_PROXY, NO_PROXY), and TLS/certificate configuration.`;
|
||||
}
|
||||
|
||||
function errorChain(error: Error) {
|
||||
const entries: Array<{ message: string; code?: string }> = [];
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
|
||||
while (current instanceof Error && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
entries.push({ message: current.message, code: errorCode(current) });
|
||||
current = current.cause;
|
||||
}
|
||||
|
||||
if (current !== undefined && current !== null && !seen.has(current)) {
|
||||
entries.push({ message: formatUnknownCause(current) });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function formatUnknownCause(value: unknown) {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const fields = safeObjectCauseFields(value);
|
||||
if (fields.length > 0) return fields.join(", ");
|
||||
return "[object cause omitted]";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const objectCauseFields = [
|
||||
"name",
|
||||
"message",
|
||||
"code",
|
||||
"errno",
|
||||
"syscall",
|
||||
"hostname",
|
||||
"host",
|
||||
"address",
|
||||
"port",
|
||||
] as const;
|
||||
|
||||
function safeObjectCauseFields(value: object) {
|
||||
return objectCauseFields.flatMap((field) => {
|
||||
const property = (value as Record<string, unknown>)[field];
|
||||
if (typeof property === "string" && property.trim().length > 0) {
|
||||
return [`${field}: ${redactSensitiveText(property)}`];
|
||||
}
|
||||
if (
|
||||
typeof property === "number" ||
|
||||
typeof property === "boolean" ||
|
||||
typeof property === "bigint"
|
||||
) {
|
||||
return [`${field}: ${String(property)}`];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function errorCode(error: Error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
function isTransportError(value: string) {
|
||||
return /fetch failed|failed to fetch|curl failed|request timed out|aborted|aborterror|enotfound|eai_again|econnrefused|econnreset|etimedout|ehostunreach|enetunreach|cert_|certificate|tls|ssl|proxy|connect tunnel|could not resolve host|operation timed out/i.test(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function classifyTransportError(value: string) {
|
||||
if (/enotfound|eai_again|could not resolve host/i.test(value)) {
|
||||
return "Network request failed: DNS lookup failed.";
|
||||
}
|
||||
if (/request timed out|etimedout|operation timed out|aborterror|aborted/i.test(value)) {
|
||||
return "Network request failed: the request timed out.";
|
||||
}
|
||||
if (/cert_|certificate|tls|ssl/i.test(value)) {
|
||||
return "Network request failed: TLS or certificate validation failed.";
|
||||
}
|
||||
if (/proxy|connect tunnel/i.test(value)) {
|
||||
return "Network request failed: proxy connection failed.";
|
||||
}
|
||||
if (/econnrefused|ehostunreach|enetunreach/i.test(value)) {
|
||||
return "Network request failed: the registry was unreachable.";
|
||||
}
|
||||
return "Network request failed.";
|
||||
}
|
||||
|
||||
function redactSensitiveText(value: string) {
|
||||
return value
|
||||
.replace(
|
||||
/(["']?(?:authorization|set-cookie|cookie|api[_-]?key|secret|token)["']?\s*:\s*["'])[^"'\n\r]+/gi,
|
||||
"$1[redacted]",
|
||||
)
|
||||
.replace(/(authorization:\s*bearer\s+)[^\s,;]+/gi, "$1[redacted]")
|
||||
.replace(/(authorization:\s*)(?!bearer\s+)(?:[^\s,;]+\s+)?[^\s,;]+/gi, "$1[redacted]")
|
||||
.replace(/(cookie:\s*)[^\n\r]+/gi, "$1[redacted]")
|
||||
.replace(/((?:api[_-]?key|secret|token):\s*)[^\s,;]+/gi, "$1[redacted]")
|
||||
.replace(/(clh_[A-Za-z0-9._-]+)/g, "[redacted]")
|
||||
.replace(
|
||||
/([?&](?:access_token|api[_-]?key|auth|authorization|callback|callback_url|code|key|redirect_uri|return_to|secret|state|token)=)[^&#\s]+/gi,
|
||||
"$1[redacted]",
|
||||
)
|
||||
.replace(/:\/\/[^/\s@]+@/g, "://[redacted]@");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user