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:
Andy Ye
2026-06-17 12:25:56 -07:00
committed by GitHub
co-authored by Patrick Erichsen
parent d6e54bdb65
commit 96b04b0837
5 changed files with 257 additions and 10 deletions
+5 -3
View File
@@ -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
View File
@@ -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",
+2 -3
View File
@@ -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));
});
+120 -1
View File
@@ -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(() => {});
+129 -2
View File
@@ -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]@");
}