claw3doctor parity

This commit is contained in:
gsknnft
2026-04-09 00:23:08 -04:00
parent 815e23dad2
commit df460d491e
6 changed files with 802 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Claw3Doctor Spec
> First-pass diagnostics plan for Claw3D deployments so users stop chasing the same setup failures manually.
## Goal
Provide a single diagnostics surface for the common "Claw3D cannot connect"
or "runtime support looks broken" cases.
The intent is similar to:
- `openclaw doctor`
- `hermes doctor`
but focused on Claw3D's integration points across providers.
## Primary Outcomes
`claw3doctor` should:
- identify the selected runtime profile/provider
- verify the gateway is reachable
- identify common auth/config mistakes
- surface provider-specific hints without making the whole app provider-specific
- reduce issue-thread back-and-forth
## V1 Delivery Boundary
`claw3doctor` v1 should be considered complete when it provides:
- selected-profile diagnostics with optional per-profile probing
- grouped terminal output with clear pass / warn / fail results
- JSON output for automation and issue reporting
- provider-aware checks for OpenClaw, Hermes, demo, local, claw3d, and custom runtimes
- common failure classification for transport and auth problems
- concrete remediation for local, remote, tunneled, and adapter-backed setups
@@ -0,0 +1,42 @@
# Runtime Profile Architecture
> Forward-looking runtime model for Claw3D after the OpenClaw + Hermes adapter work landed.
## Goal
Claw3D should treat runtime connection targets as profiles, not as ad hoc
gateway URLs tied to one backend assumption.
That means the app should model:
- provider
- runtime profile
- floor binding
instead of making the user think in terms of:
- one hard-coded backend
- one port
- one global gateway selection
## Recommendation
Use one gateway contract in the UI, with different backend providers
behind it.
Default path:
- `OpenClaw` is the default runtime profile
Optional paths:
- `Hermes Adapter`
- `Custom Runtime(s)`
- `Local Runtime`
- `Claw3D Runtime`
The important rule is:
- the UI keeps speaking one Claw3D gateway contract
- the backend behind that contract may be native OpenClaw, Hermes through
the adapter, or a custom/runtime endpoint
+1
View File
@@ -12,6 +12,7 @@
"start": "node server/index.js",
"lint": "eslint .",
"cleanup:ux-artifacts": "node scripts/cleanup-ux-artifacts.mjs",
"doctor": "node scripts/claw3doctor.mjs",
"sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts",
"studio:setup": "node scripts/studio-setup.js",
"smoke:dev-server": "node scripts/smoke-dev-server.mjs",
+93
View File
@@ -0,0 +1,93 @@
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import {
buildDoctorJsonReport,
DOCTOR_STATUSES,
formatDoctorReport,
parseDoctorArgs,
resolveRuntimeContext,
summarizeChecks,
} from "./lib/claw3doctor-core.mjs";
const require = createRequire(import.meta.url);
const {
loadUpstreamGatewaySettings,
resolveStateDir,
resolveStudioSettingsPath,
} = require("../server/studio-settings.js");
const readJsonFile = (filePath) => {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
};
const trim = (value) => (typeof value === "string" ? value.trim() : "");
const checkPass = (category, label, message) => ({
status: DOCTOR_STATUSES.pass,
category,
label,
message,
});
const checkWarn = (category, label, message) => ({
status: DOCTOR_STATUSES.warn,
category,
label,
message,
});
async function main() {
const args = parseDoctorArgs(process.argv.slice(2));
const env = process.env;
const stateDir = resolveStateDir(env);
const settingsPath = resolveStudioSettingsPath(env);
const upstreamGateway = loadUpstreamGatewaySettings(env);
const studioSettings = readJsonFile(settingsPath);
const runtimeContext = resolveRuntimeContext({
settings: studioSettings,
upstreamGateway,
env,
});
const checks = [
runtimeContext.gatewayUrl
? checkPass(
"Runtime profiles",
"Runtime profile",
`${runtimeContext.adapterType} selected at ${runtimeContext.gatewayUrl}`,
)
: checkWarn("Runtime profiles", "Runtime profile", "No runtime profile configured."),
checkPass(
"Runtime profiles",
"Gateway token",
runtimeContext.tokenConfigured ? "Configured." : "Missing.",
),
checkPass(
"Workspace",
"Studio settings path",
trim(settingsPath) || "(not found)",
),
];
const summary = summarizeChecks(checks);
const reportInput = {
summary,
runtimeContext,
paths: { stateDir, settingsPath },
checks,
};
if (args.json) {
console.log(JSON.stringify(buildDoctorJsonReport(reportInput), null, 2));
} else {
console.log(formatDoctorReport(reportInput));
}
}
await main();
+527
View File
@@ -0,0 +1,527 @@
export const DOCTOR_STATUSES = {
pass: "PASS",
warn: "WARN",
fail: "FAIL",
};
const VALID_ADAPTER_TYPES = new Set([
"openclaw",
"hermes",
"demo",
"custom",
"local",
"claw3d",
]);
const TUNNEL_HOST_PATTERN =
/(cloudflare|trycloudflare|ngrok|tailscale|ts\.net|tunnel)/i;
const DEFAULT_GATEWAY_URL_BY_ADAPTER = {
openclaw: "ws://localhost:18789",
hermes: "ws://localhost:18789",
demo: "ws://localhost:18789",
custom: "http://localhost:7770",
local: "http://localhost:7770",
claw3d: "http://localhost:3000/api/runtime/custom",
};
const isRecord = (value) =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const trimString = (value) => (typeof value === "string" ? value.trim() : "");
const supportsAnsi = () =>
Boolean(process.stdout?.isTTY && process.env.NO_COLOR !== "1");
const colorize = (text, code) =>
supportsAnsi() ? `\u001b[${code}m${text}\u001b[0m` : text;
const formatStatusBadge = (status) => {
switch (status) {
case DOCTOR_STATUSES.pass:
return colorize("[PASS]", "32");
case DOCTOR_STATUSES.warn:
return colorize("[WARN]", "33");
case DOCTOR_STATUSES.fail:
return colorize("[FAIL]", "31");
default:
return `[${status}]`;
}
};
export const normalizeAdapterType = (value, fallback = "openclaw") => {
const normalized = trimString(value).toLowerCase();
return VALID_ADAPTER_TYPES.has(normalized) ? normalized : fallback;
};
export const resolveRuntimeContext = ({
settings,
upstreamGateway,
env = process.env,
}) => {
const gateway = isRecord(settings?.gateway) ? settings.gateway : null;
const adapterType = normalizeAdapterType(
gateway?.adapterType ??
upstreamGateway?.adapterType ??
env.CLAW3D_GATEWAY_ADAPTER_TYPE,
"openclaw",
);
const rawProfiles = isRecord(gateway?.profiles) ? gateway.profiles : null;
const profiles = {};
for (const key of VALID_ADAPTER_TYPES) {
const profile = isRecord(rawProfiles?.[key]) ? rawProfiles[key] : null;
const url = trimString(profile?.url);
const token = trimString(profile?.token);
if (!url) continue;
profiles[key] = { url, token };
}
const upstreamUrl = trimString(upstreamGateway?.url);
const selectedProfile = profiles[adapterType]
? profiles[adapterType]
: upstreamUrl
? {
url: upstreamUrl,
token: trimString(upstreamGateway?.token),
}
: {
url: DEFAULT_GATEWAY_URL_BY_ADAPTER[adapterType],
token: "",
};
if (selectedProfile?.url && !profiles[adapterType]) {
profiles[adapterType] = {
url: selectedProfile.url,
token: selectedProfile.token ?? "",
};
}
return {
adapterType,
gatewayUrl: selectedProfile?.url ?? "",
token: selectedProfile?.token ?? "",
tokenConfigured: Boolean(selectedProfile?.token),
profiles,
};
};
export const buildGatewayWarnings = ({
gatewayUrl,
studioAccessToken = "",
host = "",
}) => {
const warnings = [];
const url = trimString(gatewayUrl);
if (!url) {
warnings.push("No gateway URL configured.");
return warnings;
}
let parsed = null;
try {
parsed = new URL(url);
} catch {
warnings.push("Gateway URL is not a valid URL.");
return warnings;
}
const protocol = parsed.protocol.toLowerCase();
const hostname = parsed.hostname.toLowerCase();
const isLocalHost =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.endsWith(".local");
const isRemote = !isLocalHost;
if (isRemote && protocol === "ws:") {
warnings.push(
"Remote gateway uses ws://. Public or cross-device browser connections usually need wss:// or an HTTPS-backed Studio proxy.",
);
}
if (isRemote && TUNNEL_HOST_PATTERN.test(hostname)) {
warnings.push(
"Gateway host looks tunnel-backed. If connect fails, compare direct local/LAN behavior before debugging the runtime itself.",
);
}
const normalizedHost = trimString(host).toLowerCase();
const publicStudioHost =
normalizedHost &&
normalizedHost !== "localhost" &&
normalizedHost !== "127.0.0.1" &&
normalizedHost !== "::1" &&
normalizedHost !== "0.0.0.0";
if (publicStudioHost && !trimString(studioAccessToken)) {
warnings.push(
"Studio appears to be configured for a public host without STUDIO_ACCESS_TOKEN. Remote admin access should not be exposed that way.",
);
}
return warnings;
};
export const buildProfileWarnings = ({ runtimeContext }) => {
const warnings = [];
const urlToAdapters = new Map();
for (const [adapterType, profile] of Object.entries(
runtimeContext?.profiles ?? {},
)) {
const url = trimString(profile?.url);
if (!url) continue;
const key = url.toLowerCase();
const adapters = urlToAdapters.get(key) ?? [];
adapters.push(adapterType);
urlToAdapters.set(key, adapters);
}
for (const [url, adapters] of urlToAdapters.entries()) {
if (adapters.length < 2) continue;
warnings.push(
`Multiple runtime profiles share the same endpoint (${url}): ${adapters.join(", ")}. That is fine for one-runtime-at-a-time local use, but simultaneous runtimes need distinct URLs or ports.`,
);
}
return warnings;
};
export const buildOpenClawWarnings = ({
gatewayUrl,
tokenConfigured = false,
}) => {
const warnings = [];
const url = trimString(gatewayUrl);
if (!url) return warnings;
let parsed = null;
try {
parsed = new URL(url);
} catch {
return warnings;
}
const hostname = parsed.hostname.toLowerCase();
const isLocalHost =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.endsWith(".local");
if (isLocalHost) {
return warnings;
}
if (!tokenConfigured) {
warnings.push(
"Remote OpenClaw profile has no gateway token configured. Remote/browser clients often fail with pairing or approval-style errors until the device or token path is approved.",
);
}
if (TUNNEL_HOST_PATTERN.test(hostname)) {
warnings.push(
"Remote OpenClaw host looks tunnel-backed. If you hit 1008/1011/1012-style failures, verify direct local or LAN access first, then check pairing/device approval and reverse-proxy websocket handling.",
);
}
return warnings;
};
export const buildCustomRuntimeWarnings = ({
gatewayUrl,
allowlist = "",
nodeEnv = "",
}) => {
const warnings = [];
const url = trimString(gatewayUrl);
if (!url) return warnings;
let parsed = null;
try {
parsed = new URL(url);
} catch {
warnings.push("Custom runtime URL is not a valid URL.");
return warnings;
}
if (parsed.protocol === "ws:" || parsed.protocol === "wss:") {
warnings.push(
"Custom runtime profile uses a websocket URL. The custom provider boundary is expected to expose an HTTP API (for example /health and /v1/chat/completions).",
);
}
const isProduction = trimString(nodeEnv).toLowerCase() === "production";
const hostname = parsed.hostname.toLowerCase();
const isLocalHost =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.endsWith(".local");
if (isProduction && !isLocalHost && !trimString(allowlist)) {
warnings.push(
"Production custom runtime is configured without CUSTOM_RUNTIME_ALLOWLIST or UPSTREAM_ALLOWLIST. The runtime proxy should not rely on open-host defaults there.",
);
}
return warnings;
};
export const buildGatewayFailureActions = ({
adapterType,
message = "",
gatewayUrl = "",
}) => {
const actions = [];
const normalized = trimString(message).toLowerCase();
const url = trimString(gatewayUrl);
let parsedUrl = null;
try {
parsedUrl = url ? new URL(url) : null;
} catch {}
const hostname = parsedUrl?.hostname?.toLowerCase() ?? "";
const isTunnelBacked = Boolean(
hostname && TUNNEL_HOST_PATTERN.test(hostname),
);
const isCloudflare = hostname.includes("cloudflare");
const isTailscale =
hostname.includes("tailscale") || hostname.endsWith("ts.net");
if (normalized.includes("econnrefused") || normalized.includes("timed out")) {
actions.push(
"Verify the backend is actually listening on the configured host and port before retrying from Claw3D.",
);
}
if (normalized.includes("1011")) {
actions.push(
"If this is OpenClaw behind a reverse proxy or tunnel, verify websocket upgrade handling and compare direct local/LAN behavior before assuming the runtime itself is broken.",
);
}
if (normalized.includes("1012")) {
actions.push(
"A 1012-style close usually means the upstream is restarting or unavailable temporarily. Retry after checking the backend service logs.",
);
}
if (
normalized.includes("1008") ||
normalized.includes("pairing required") ||
normalized.includes("approve")
) {
actions.push(
"For OpenClaw, check pending device/browser approval with `openclaw devices list` and approve the request before retrying the remote browser session.",
);
}
if (
normalized.includes("401") ||
normalized.includes("403") ||
normalized.includes("unexpected http 401")
) {
actions.push(
"Recheck the configured token/auth path. The gateway or proxy is rejecting the connection before the office can load.",
);
}
if (isCloudflare) {
actions.push(
"For Cloudflare or similar HTTPS tunnels, verify websocket upgrade forwarding and prefer an HTTPS-backed Studio path rather than a bare ws:// remote endpoint.",
);
}
if (isTailscale) {
actions.push(
"For Tailnet-hosted OpenClaw, test the same gateway directly on local/LAN first, then compare against the Tailnet URL so pairing/proxy issues do not get conflated.",
);
}
if (isTunnelBacked) {
actions.push(
"Because this endpoint looks tunnel-backed, reproduce once via direct local or LAN access to separate runtime problems from tunnel/proxy problems.",
);
}
if (adapterType === "custom" || adapterType === "local" || adapterType === "claw3d") {
actions.push(
"Custom-style runtimes should answer over HTTP on /health or /registry, not just a raw websocket endpoint.",
);
}
return [...new Set(actions)];
};
export const classifyGatewayFailure = ({ message = "" }) => {
const normalized = trimString(message).toLowerCase();
if (!normalized) return null;
if (normalized.includes("1008") || normalized.includes("pairing required")) {
return {
code: "1008",
label: "Policy or pairing gate",
message:
"The upstream is rejecting this session for policy/pairing reasons. Check device approval, browser identity, and token flow.",
};
}
if (normalized.includes("1011")) {
return {
code: "1011",
label: "Upstream runtime or proxy failure",
message:
"The websocket upgraded but the upstream failed mid-connect or during runtime handling. Check runtime logs and reverse-proxy websocket support.",
};
}
if (normalized.includes("1012")) {
return {
code: "1012",
label: "Service restart or temporary unavailability",
message:
"The upstream likely restarted or was briefly unavailable. Recheck service health and retry once the backend settles.",
};
}
if (
normalized.includes("401") ||
normalized.includes("403") ||
normalized.includes("unexpected http 401") ||
normalized.includes("unexpected http 403")
) {
return {
code: normalized.includes("403") ? "403" : "401",
label: "Auth rejection",
message:
"The upstream or proxy rejected auth before the office connected. Recheck the selected profile token, studio access path, and adapter env alignment.",
};
}
if (normalized.includes("econnrefused")) {
return {
code: "ECONNREFUSED",
label: "Listener missing",
message:
"Nothing is listening on the configured host/port. Start the backend or fix the profile URL before retrying.",
};
}
if (normalized.includes("timed out")) {
return {
code: "TIMEOUT",
label: "Connection timeout",
message:
"The endpoint did not complete the handshake in time. Check proxy path, host reachability, and whether the backend is overloaded or hanging.",
};
}
return null;
};
export const summarizeChecks = (checks) => {
let hasFail = false;
let hasWarn = false;
for (const check of checks) {
if (check.status === DOCTOR_STATUSES.fail) hasFail = true;
if (check.status === DOCTOR_STATUSES.warn) hasWarn = true;
}
if (hasFail) return DOCTOR_STATUSES.fail;
if (hasWarn) return DOCTOR_STATUSES.warn;
return DOCTOR_STATUSES.pass;
};
export const shouldRunHermesChecks = ({ runtimeContext, env = process.env }) =>
runtimeContext.adapterType === "hermes" ||
Boolean(trimString(env.HERMES_API_URL) || trimString(env.HERMES_ADAPTER_PORT));
export const shouldRunOpenClawChecks = ({
runtimeContext,
openclawConfigExists = false,
}) => runtimeContext.adapterType === "openclaw" || openclawConfigExists;
export const shouldRunDemoChecks = ({ runtimeContext, env = process.env }) =>
runtimeContext.adapterType === "demo" ||
Boolean(trimString(env.DEMO_ADAPTER_PORT));
export const shouldRunCustomChecks = ({ runtimeContext }) =>
runtimeContext.adapterType === "custom" ||
runtimeContext.adapterType === "local" ||
runtimeContext.adapterType === "claw3d";
export const formatDoctorReport = ({
summary,
runtimeContext,
paths,
checks,
}) => {
const summaryCounts = {
pass: checks.filter((check) => check.status === DOCTOR_STATUSES.pass).length,
warn: checks.filter((check) => check.status === DOCTOR_STATUSES.warn).length,
fail: checks.filter((check) => check.status === DOCTOR_STATUSES.fail).length,
};
const groupedChecks = new Map();
for (const check of checks) {
const category = check.category || "General";
const entries = groupedChecks.get(category) ?? [];
entries.push(check);
groupedChecks.set(category, entries);
}
const lines = [];
lines.push("==================================================");
lines.push(`Claw3Doctor ${formatStatusBadge(summary)}`);
lines.push("==================================================");
lines.push("");
lines.push(`Runtime provider: ${runtimeContext.adapterType}`);
lines.push(`Selected profile: ${runtimeContext.gatewayUrl || "(not configured)"}`);
lines.push(`Gateway token: ${runtimeContext.tokenConfigured ? "configured" : "missing"}`);
lines.push(`State dir: ${paths.stateDir}`);
lines.push(`Studio settings: ${paths.settingsPath}`);
const configuredProfiles = Object.entries(runtimeContext.profiles ?? {});
if (configuredProfiles.length > 0) {
lines.push("Configured profiles:");
for (const [adapterType, profile] of configuredProfiles) {
lines.push(` - ${adapterType}: ${profile.url}`);
}
}
lines.push(
`Check counts: ${summaryCounts.pass} pass, ${summaryCounts.warn} warn, ${summaryCounts.fail} fail`,
);
lines.push("");
for (const [category, categoryChecks] of groupedChecks.entries()) {
lines.push(category);
lines.push("-".repeat(category.length));
for (const check of categoryChecks) {
lines.push(` ${formatStatusBadge(check.status)} ${check.label}: ${check.message}`);
}
lines.push("");
}
const actions = checks.flatMap((check) => check.actions ?? []);
if (actions.length > 0) {
lines.push("Suggested next actions:");
actions.forEach((action, index) => {
lines.push(`${index + 1}. ${action}`);
});
}
return lines.join("\n");
};
export const buildDoctorJsonReport = ({
summary,
runtimeContext,
paths,
checks,
}) => ({
doctor: "claw3doctor",
summary,
runtimeContext,
paths,
checks,
counts: {
pass: checks.filter((check) => check.status === DOCTOR_STATUSES.pass).length,
warn: checks.filter((check) => check.status === DOCTOR_STATUSES.warn).length,
fail: checks.filter((check) => check.status === DOCTOR_STATUSES.fail).length,
},
});
export const parseDoctorArgs = (argv) => {
const args = {
json: false,
allProfiles: false,
profile: null,
};
for (let index = 0; index < argv.length; index += 1) {
const entry = argv[index];
if (entry === "--json") {
args.json = true;
continue;
}
if (entry === "--all-profiles") {
args.allProfiles = true;
continue;
}
if (entry === "--profile") {
const next = trimString(argv[index + 1] ?? "").toLowerCase();
if (next) {
args.profile = next;
index += 1;
}
}
}
return args;
};
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
buildGatewayWarnings,
buildProfileWarnings,
DOCTOR_STATUSES,
parseDoctorArgs,
resolveRuntimeContext,
shouldRunCustomChecks,
summarizeChecks,
} from "../../scripts/lib/claw3doctor-core.mjs";
describe("claw3doctor core", () => {
it("resolves selected runtime from settings profiles", () => {
const runtime = resolveRuntimeContext({
settings: {
gateway: {
adapterType: "hermes",
url: "ws://localhost:18790",
token: "",
profiles: {
hermes: { url: "ws://localhost:18790", token: "" },
openclaw: { url: "ws://localhost:18789", token: "file-token" },
},
},
},
upstreamGateway: {
url: "ws://localhost:18789",
token: "file-token",
adapterType: "openclaw",
},
env: process.env,
});
expect(runtime).toMatchObject({
adapterType: "hermes",
gatewayUrl: "ws://localhost:18790",
tokenConfigured: false,
});
});
it("supports local and claw3d runtime defaults", () => {
expect(
resolveRuntimeContext({
settings: { gateway: { adapterType: "local" } },
upstreamGateway: { url: "", token: "", adapterType: "local" },
env: process.env,
}).gatewayUrl,
).toBe("http://localhost:7770");
expect(
resolveRuntimeContext({
settings: { gateway: { adapterType: "claw3d" } },
upstreamGateway: { url: "", token: "", adapterType: "claw3d" },
env: process.env,
}).gatewayUrl,
).toBe("http://localhost:3000/api/runtime/custom");
});
it("warns on insecure remote websocket", () => {
expect(
buildGatewayWarnings({
gatewayUrl: "ws://pi5.example.com:18789",
studioAccessToken: "",
host: "pi5.example.com",
}),
).toEqual(expect.arrayContaining([expect.stringContaining("ws://")]));
});
it("warns when multiple runtime profiles share the same endpoint", () => {
expect(
buildProfileWarnings({
runtimeContext: {
profiles: {
openclaw: { url: "ws://localhost:18789", token: "a" },
hermes: { url: "ws://localhost:18789", token: "" },
},
},
}),
).toEqual(expect.arrayContaining([expect.stringContaining("same endpoint")]));
});
it("parses args", () => {
expect(parseDoctorArgs(["--json", "--profile", "openclaw"])).toEqual({
json: true,
allProfiles: false,
profile: "openclaw",
});
});
it("treats local/claw3d as custom-style checks", () => {
expect(shouldRunCustomChecks({ runtimeContext: { adapterType: "local" } })).toBe(true);
expect(shouldRunCustomChecks({ runtimeContext: { adapterType: "claw3d" } })).toBe(true);
});
it("summarizes checks by worst status", () => {
expect(
summarizeChecks([
{ status: DOCTOR_STATUSES.pass },
{ status: DOCTOR_STATUSES.warn },
]),
).toBe(DOCTOR_STATUSES.warn);
});
});