From 51b360d329d3077f988de49133cf2d81eafcc0df Mon Sep 17 00:00:00 2001 From: KrasimirKralev Date: Tue, 11 Aug 2026 11:03:54 +0300 Subject: [PATCH 1/4] fix(ai-models): resolve ClawBox AI status from the Hermes config on hermes edition The status route read only `~/.openclaw/openclaw.json`, which does not exist on a Hermes device, so a signed-in box reported clawaiConfigured=false and the Remote Control panel kept prompting the user to sign in. Resolve the active provider, model and ClawBox AI profile from the Hermes config (via the mtime-memoised CLI helper) when the active harness is Hermes, keeping the OpenClaw path unchanged. --- src/app/setup-api/ai-models/status/route.ts | 282 ++++++++++++-------- src/tests/routes/ai-models/status.test.ts | 97 +++++++ 2 files changed, 274 insertions(+), 105 deletions(-) diff --git a/src/app/setup-api/ai-models/status/route.ts b/src/app/setup-api/ai-models/status/route.ts index b5305bcb0..5c00560e5 100644 --- a/src/app/setup-api/ai-models/status/route.ts +++ b/src/app/setup-api/ai-models/status/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { readConfig } from "@/lib/openclaw-config"; import { get as getConfigValue, set as setConfigValue } from "@/lib/config-store"; import { normalizeClawboxAiTier, type ClawboxAiTier } from "@/lib/clawbox-ai-models"; +import { getActiveHarness } from "@/lib/harness"; +import { hermesConfigGetMany } from "@/lib/hermes-config-cache"; export const dynamic = "force-dynamic"; @@ -16,6 +18,10 @@ const PROVIDER_LABELS: Record = { }; const CLAWBOX_AI_TIER_CONFIG_KEY = "clawai_tier"; +// Config-store key holding the ClawBox AI portal token on a Hermes device. +// `applyClawaiToHermes` writes it here; the OpenClaw path reads the same token +// from `models.providers.deepseek.apiKey` in openclaw.json instead. +const CLAWBOX_AI_TOKEN_CONFIG_KEY = "clawai_token"; // Portal endpoint that maps a `claw_*` token to its current subscription // state. Authoritative source for the device's tier badge — local config @@ -216,119 +222,185 @@ function normalizeProvider(provider: string | null): string | null { return normalized; } -export async function GET() { - try { - const config = await readConfig(); - - const profiles = config.auth?.profiles ?? {}; - const model = config.agents?.defaults?.model?.primary ?? null; - - // Match the active profile against the primary model so legacy/fallback - // profiles (e.g. ClawBox AI added as a fallback alongside the user's - // chosen provider) don't get reported as the active one. - const profileKeys = Object.keys(profiles); - // Normalize both sides through normalizeProvider so the deepseek/clawai - // alias collapses correctly. Without this, a primary model of - // `clawai/deepseek-v4-pro` (canonical) would never match a profile - // recorded under the wire-format `deepseek` provider, and we'd silently - // fall back to profileKeys[0]. - const primaryProviderHint = normalizeProvider(model ? model.split("/")[0] : null); - let activeKey: string | undefined; - if (primaryProviderHint) { - activeKey = profileKeys.find((key) => { - const entry = profiles[key]; - const entryProvider = normalizeProvider(entry?.provider ?? key.split(":")[0]); - return entryProvider === primaryProviderHint; - }); - } - activeKey ??= profileKeys[0]; - - let provider: string | null = null; - let mode: string | null = null; - if (activeKey) { - const entry = profiles[activeKey]; - provider = entry?.provider ?? activeKey.split(":")[0]; - mode = entry?.mode ?? null; - } - const normalizedProvider = normalizeProvider(provider); - - // ClawBox AI account entitlement is independent of which provider is - // currently driving the chat. A Max subscriber chatting via OpenAI - // still has the paid plan that unlocks ClawKeep + Remote Desktop — - // resolving the tier off the active profile alone (the old - // behaviour) falsely blocks them. - // - // Walk every profile for any clawai/deepseek entry, look up the - // stored claw_ token's tier on the portal, and surface that as - // `clawaiAccountTier`. The badge-facing `clawaiTier` field stays - // tied to the active chat provider so the chat-header badge keeps - // its current behaviour (no badge when chatting via OpenAI). - const localTier = normalizeClawboxAiTier( - await getConfigValue(CLAWBOX_AI_TIER_CONFIG_KEY).catch(() => null), - ); - const clawaiTokenCandidate = config.models?.providers?.deepseek?.apiKey; - const clawaiToken = typeof clawaiTokenCandidate === "string" && clawaiTokenCandidate.startsWith("claw_") - ? clawaiTokenCandidate - : null; - const hasClawaiProfile = profileKeys.some((key) => { +/** + * The provider/model/ClawBox-AI facts the status response is built from, + * resolved from whichever agent config the active harness actually uses. + */ +interface ResolvedAiState { + /** Active chat provider in wire form (pre-normalization), or null. */ + provider: string | null; + /** Auth mode of the active profile, when the config records one. */ + mode: string | null; + /** Primary/default model id, or null. */ + model: string | null; + /** True when a ClawBox AI profile is configured (independent of active provider). */ + hasClawaiProfile: boolean; + /** The paired `claw_*` portal token, or null when none/not a portal token. */ + clawaiToken: string | null; +} + +/** + * OpenClaw path: read `~/.openclaw/openclaw.json`. The active profile is matched + * against the primary model so a ClawBox AI fallback profile alongside the + * user's chosen provider isn't reported as the active one. + */ +async function resolveOpenclawAiState(): Promise { + const config = await readConfig(); + + const profiles = config.auth?.profiles ?? {}; + const model = config.agents?.defaults?.model?.primary ?? null; + + const profileKeys = Object.keys(profiles); + // Normalize both sides through normalizeProvider so the deepseek/clawai + // alias collapses correctly. Without this, a primary model of + // `clawai/deepseek-v4-pro` (canonical) would never match a profile + // recorded under the wire-format `deepseek` provider, and we'd silently + // fall back to profileKeys[0]. + const primaryProviderHint = normalizeProvider(model ? model.split("/")[0] : null); + let activeKey: string | undefined; + if (primaryProviderHint) { + activeKey = profileKeys.find((key) => { const entry = profiles[key]; const entryProvider = normalizeProvider(entry?.provider ?? key.split(":")[0]); - return entryProvider === "clawai"; + return entryProvider === primaryProviderHint; }); + } + activeKey ??= profileKeys[0]; + + let provider: string | null = null; + let mode: string | null = null; + if (activeKey) { + const entry = profiles[activeKey]; + provider = entry?.provider ?? activeKey.split(":")[0]; + mode = entry?.mode ?? null; + } + + const clawaiTokenCandidate = config.models?.providers?.deepseek?.apiKey; + const clawaiToken = typeof clawaiTokenCandidate === "string" && clawaiTokenCandidate.startsWith("claw_") + ? clawaiTokenCandidate + : null; + const hasClawaiProfile = profileKeys.some((key) => { + const entry = profiles[key]; + const entryProvider = normalizeProvider(entry?.provider ?? key.split(":")[0]); + return entryProvider === "clawai"; + }); + + return { provider, mode, model, hasClawaiProfile, clawaiToken }; +} + +/** + * Hermes path: there is no OpenClaw config on a Hermes device, so resolve the + * same facts from Hermes' own config (`~/.hermes/config.yaml`, read through the + * mtime-memoised CLI helper) plus the config-store token. `applyClawaiToHermes` + * writes `providers.clawai.*` when ClawBox AI is set up and points + * `model.provider` at it, so those are the authoritative signals here — the + * same ones `/setup-api/hermes/clawai` reads. Without this, the OpenClaw-only + * read left `clawaiConfigured` false on a signed-in Hermes box and the Remote + * Control panel kept prompting the user to sign in. + */ +async function resolveHermesAiState(): Promise { + const cfg = await hermesConfigGetMany([ + "model.provider", + "model.default", + "providers.clawai.base_url", + ]); + const provider = cfg["model.provider"] || null; + const model = cfg["model.default"] || null; + // The provider block persists even when the user later switches the active + // provider to something else, so its presence — not the active provider — is + // what "a ClawBox AI account is configured" means. + const hasClawaiProfile = Boolean(cfg["providers.clawai.base_url"]); + + const tokenRaw = await getConfigValue(CLAWBOX_AI_TOKEN_CONFIG_KEY).catch(() => null); + const clawaiToken = typeof tokenRaw === "string" && tokenRaw.startsWith("claw_") ? tokenRaw : null; + + // Hermes has no per-profile "mode" concept the way OpenClaw's auth.profiles + // does, so leave it null (the OpenClaw path filled it from the profile). + return { provider, mode: null, model, hasClawaiProfile, clawaiToken }; +} + +/** + * Build the status response from resolved AI state. Shared by both harness + * paths so the ClawBox AI tier/portal-reconcile logic can never drift between + * them. + */ +async function buildStatusResponse(state: ResolvedAiState): Promise { + const normalizedProvider = normalizeProvider(state.provider); - let clawaiAccountTier: ClawboxAiTier | null = null; - let accountTierSource: "portal" | "picker" = "picker"; - if (hasClawaiProfile) { - clawaiAccountTier = localTier; - // Ask the portal whenever a clawai token is paired, regardless - // of whether we have a local tier yet. This is what makes - // Free → Paid upgrades visible without forcing a re-login. - // mapPortalTier now gates non-null returns on a paid plan, so - // a bogus deviceTier stamp can no longer promote a Free user. - if (clawaiToken) { - const lookup = await fetchPortalTier(clawaiToken); - if (lookup.source === "portal") { - clawaiAccountTier = lookup.tier; - accountTierSource = "portal"; - // Persist the portal-confirmed tier so the portal-unreachable - // fallback reflects the last *confirmed* tier, not a stale - // configure-time value (which flapped a Free badge to Pro and - // re-fired the celebration). Write only on change to avoid churn. - if (lookup.tier !== localTier) { - await setConfigValue(CLAWBOX_AI_TIER_CONFIG_KEY, lookup.tier).catch(() => {}); - } + // ClawBox AI account entitlement is independent of which provider is + // currently driving the chat. A Max subscriber chatting via OpenAI + // still has the paid plan that unlocks ClawKeep + Remote Desktop — + // resolving the tier off the active profile alone (the old + // behaviour) falsely blocks them. + const localTier = normalizeClawboxAiTier( + await getConfigValue(CLAWBOX_AI_TIER_CONFIG_KEY).catch(() => null), + ); + + let clawaiAccountTier: ClawboxAiTier | null = null; + let accountTierSource: "portal" | "picker" = "picker"; + if (state.hasClawaiProfile) { + clawaiAccountTier = localTier; + // Ask the portal whenever a clawai token is paired, regardless + // of whether we have a local tier yet. This is what makes + // Free → Paid upgrades visible without forcing a re-login. + // mapPortalTier now gates non-null returns on a paid plan, so + // a bogus deviceTier stamp can no longer promote a Free user. + if (state.clawaiToken) { + const lookup = await fetchPortalTier(state.clawaiToken); + if (lookup.source === "portal") { + clawaiAccountTier = lookup.tier; + accountTierSource = "portal"; + // Persist the portal-confirmed tier so the portal-unreachable + // fallback reflects the last *confirmed* tier, not a stale + // configure-time value (which flapped a Free badge to Pro and + // re-fired the celebration). Write only on change to avoid churn. + if (lookup.tier !== localTier) { + await setConfigValue(CLAWBOX_AI_TIER_CONFIG_KEY, lookup.tier).catch(() => {}); } } } + } - // The badge-facing tier mirrors the account tier *only* when - // ClawBox AI is the active chat provider. Switching to OpenAI in - // the chat dropdown should hide the chat-header tier badge — the - // user isn't currently chatting with ClawBox AI — without - // demoting their account-level entitlement. - const clawaiTier = normalizedProvider === "clawai" ? clawaiAccountTier : null; - const tierSource = normalizedProvider === "clawai" ? accountTierSource : "picker"; - - return NextResponse.json({ - connected: !!normalizedProvider, - provider: normalizedProvider, - providerLabel: normalizedProvider ? (PROVIDER_LABELS[normalizedProvider] ?? normalizedProvider) : null, - mode, - model, - clawaiTier, - clawaiAccountTier, - // Whether *any* clawai profile is configured. Distinguishes - // "no ClawBox AI account at all" (false) from "Free user with - // a paired clawai token" (true, clawaiAccountTier=null) — the - // hook needs this to gate ClawKeep / Remote Desktop sign-in - // prompts independently of paid-tier checks. - clawaiConfigured: hasClawaiProfile, - tierSource, - }, { - headers: { - "Cache-Control": "no-store", - }, - }); + // The badge-facing tier mirrors the account tier *only* when + // ClawBox AI is the active chat provider. Switching to OpenAI in + // the chat dropdown should hide the chat-header tier badge — the + // user isn't currently chatting with ClawBox AI — without + // demoting their account-level entitlement. + const clawaiTier = normalizedProvider === "clawai" ? clawaiAccountTier : null; + const tierSource = normalizedProvider === "clawai" ? accountTierSource : "picker"; + + return NextResponse.json({ + connected: !!normalizedProvider, + provider: normalizedProvider, + providerLabel: normalizedProvider ? (PROVIDER_LABELS[normalizedProvider] ?? normalizedProvider) : null, + mode: state.mode, + model: state.model, + clawaiTier, + clawaiAccountTier, + // Whether *any* clawai profile is configured. Distinguishes + // "no ClawBox AI account at all" (false) from "Free user with + // a paired clawai token" (true, clawaiAccountTier=null) — the + // hook needs this to gate ClawKeep / Remote Desktop sign-in + // prompts independently of paid-tier checks. + clawaiConfigured: state.hasClawaiProfile, + tierSource, + }, { + headers: { + "Cache-Control": "no-store", + }, + }); +} + +export async function GET() { + try { + // Resolve from whichever config the active harness actually uses. A Hermes + // device has no OpenClaw config at all, so reading only openclaw.json there + // reported "no ClawBox AI" for a signed-in box. + const harness = await getActiveHarness(); + const state = harness === "hermes" + ? await resolveHermesAiState() + : await resolveOpenclawAiState(); + return await buildStatusResponse(state); } catch { return NextResponse.json( { connected: false, provider: null, providerLabel: null, mode: null, model: null, clawaiTier: null, clawaiAccountTier: null, clawaiConfigured: false, tierSource: "picker" }, diff --git a/src/tests/routes/ai-models/status.test.ts b/src/tests/routes/ai-models/status.test.ts index 7a6fea64a..43cd605c5 100644 --- a/src/tests/routes/ai-models/status.test.ts +++ b/src/tests/routes/ai-models/status.test.ts @@ -9,12 +9,24 @@ vi.mock("@/lib/config-store", () => ({ set: vi.fn(), })); +vi.mock("@/lib/harness", () => ({ + getActiveHarness: vi.fn(), +})); + +vi.mock("@/lib/hermes-config-cache", () => ({ + hermesConfigGetMany: vi.fn(), +})); + import { readConfig } from "@/lib/openclaw-config"; import { get as getConfigValue, set as setConfigValue } from "@/lib/config-store"; +import { getActiveHarness } from "@/lib/harness"; +import { hermesConfigGetMany } from "@/lib/hermes-config-cache"; const mockReadConfig = vi.mocked(readConfig); const mockGetConfigValue = vi.mocked(getConfigValue); const mockSetConfigValue = vi.mocked(setConfigValue); +const mockGetActiveHarness = vi.mocked(getActiveHarness); +const mockHermesConfigGetMany = vi.mocked(hermesConfigGetMany); describe("/setup-api/ai-models/status", () => { let GET: () => Promise; @@ -26,6 +38,10 @@ describe("/setup-api/ai-models/status", () => { vi.clearAllMocks(); mockGetConfigValue.mockResolvedValue(null); mockSetConfigValue.mockResolvedValue(undefined); + // Default to the OpenClaw harness so the existing suite exercises the + // openclaw.json path unchanged; the Hermes suite overrides this. + mockGetActiveHarness.mockResolvedValue("openclaw"); + mockHermesConfigGetMany.mockResolvedValue({}); fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); const mod = await import("@/app/setup-api/ai-models/status/route"); @@ -488,6 +504,87 @@ describe("/setup-api/ai-models/status", () => { }); }); + describe("hermes edition", () => { + beforeEach(() => { + // Active harness is Hermes: there is no openclaw.json, so the route must + // resolve ClawBox AI from the Hermes config instead. + mockGetActiveHarness.mockResolvedValue("hermes"); + }); + + it("reports clawaiConfigured=true for a signed-in Hermes box (the Remote Control banner fix)", async () => { + mockHermesConfigGetMany.mockResolvedValue({ + "model.provider": "clawai", + "model.default": "deepseek-v4-flash", + "providers.clawai.base_url": "https://clawbox.com/api/ai", + }); + mockGetConfigValue.mockImplementation(async (key: string) => + key === "clawai_token" ? "claw_hermes123" : key === "clawai_tier" ? "flash" : null, + ); + fetchSpy.mockResolvedValue(new Response( + JSON.stringify({ tier: "free", deviceTier: null }), + { status: 200 }, + )); + + const res = await GET(); + const body = await res.json(); + + expect(body.provider).toBe("clawai"); + expect(body.providerLabel).toBe("ClawBox AI"); + expect(body.connected).toBe(true); + expect(body.clawaiConfigured).toBe(true); + // The OpenClaw config must NOT be read on a Hermes box. + expect(mockReadConfig).not.toHaveBeenCalled(); + // The portal was consulted with the token stored in the Hermes config-store. + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/api/clawbox-ai/device-info"), + expect.objectContaining({ headers: { Authorization: "Bearer claw_hermes123" } }), + ); + }); + + it("reports clawaiConfigured=false when the Hermes config has no ClawBox AI provider block", async () => { + mockHermesConfigGetMany.mockResolvedValue({ + "model.provider": "openrouter", + "model.default": "openrouter/anthropic/claude-haiku-4.5", + "providers.clawai.base_url": "", + }); + + const res = await GET(); + const body = await res.json(); + + expect(body.provider).toBe("openrouter"); + expect(body.clawaiConfigured).toBe(false); + expect(body.clawaiTier).toBeNull(); + expect(mockReadConfig).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("keeps the account tier available when ClawBox AI is configured but not the active provider", async () => { + // ClawBox AI provider block present, but the user switched the active + // provider to the on-device model — loggedIn must stay true. + mockHermesConfigGetMany.mockResolvedValue({ + "model.provider": "clawlocal", + "model.default": "gemma4-e2b-it-q4_0", + "providers.clawai.base_url": "https://clawbox.com/api/ai", + }); + mockGetConfigValue.mockImplementation(async (key: string) => + key === "clawai_token" ? "claw_hermes456" : key === "clawai_tier" ? "pro" : null, + ); + fetchSpy.mockResolvedValue(new Response( + JSON.stringify({ tier: "max", deviceTier: "pro" }), + { status: 200 }, + )); + + const res = await GET(); + const body = await res.json(); + + expect(body.clawaiConfigured).toBe(true); + // Active provider isn't clawai, so the header badge tier is blank... + expect(body.clawaiTier).toBeNull(); + // ...but the account tier is still resolved for ClawKeep / Remote Control. + expect(body.clawaiAccountTier).toBe("pro"); + }); + }); + it("normalizes the codex provider (and legacy openai-codex) for the UI", async () => { mockReadConfig.mockResolvedValue({ auth: { From b657b536b61a40e569d4b6ad65c528409fbdd7d8 Mon Sep 17 00:00:00 2001 From: KrasimirKralev Date: Tue, 11 Aug 2026 11:04:06 +0300 Subject: [PATCH 2/4] fix(openclaw): refuse the openclaw CLI on editions that ship no binary The Hermes SKU installs no openclaw binary, so findOpenclawBin's bare "openclaw" fallback turned every spawn into a raw "spawn openclaw ENOENT" deep inside a request. Add a typed OpenclawUnavailableError and an openclawIsAbsent() edition check, guard the shared spawnOpenclaw chokepoint with it, and skip the openclaw spawns in the model-catalog refresh, the local-ai disable path and the updater version/doctor probes. openclaw and dual editions are unaffected. --- src/app/setup-api/ai-models/catalog/route.ts | 14 ++++++- src/app/setup-api/local-ai/route.ts | 21 ++++++---- src/lib/openclaw-config.ts | 33 +++++++++++++++ src/lib/updater.ts | 17 +++++--- .../routes/ai-models/catalog-edition.test.ts | 41 +++++++++++++++++++ src/tests/unit/openclaw-config.test.ts | 27 ++++++++++++ 6 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 src/tests/routes/ai-models/catalog-edition.test.ts diff --git a/src/app/setup-api/ai-models/catalog/route.ts b/src/app/setup-api/ai-models/catalog/route.ts index 254d87ad3..0b0f8953c 100644 --- a/src/app/setup-api/ai-models/catalog/route.ts +++ b/src/app/setup-api/ai-models/catalog/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { spawn } from "child_process"; import { promises as fsp } from "fs"; import path from "path"; -import { findOpenclawBin } from "@/lib/openclaw-config"; +import { findOpenclawBin, openclawIsAbsent } from "@/lib/openclaw-config"; import { DATA_DIR } from "@/lib/config-store"; import { CATALOG_PROVIDERS, isCatalogProvider, PROVIDER_CATALOGS } from "@/lib/provider-models"; @@ -431,6 +431,18 @@ async function fetchOpenRouterCatalog(): Promise { // next service restart. export function refreshInBackground(provider: string): void { if (refreshing.has(provider)) return; + + // OpenRouter (REST) and ClawBox AI (static) never touch the CLI; every other + // provider's catalog comes from `openclaw models list`. On an edition without + // the binary (Hermes) that spawn is a guaranteed ENOENT, so skip it cleanly + // rather than fork a missing binary for each provider on every boot warmup. + // Hermes surfaces its own model list through the Hermes dashboard, not here. + const usesOpenclawCli = provider !== "openrouter" && provider !== "clawai"; + if (usesOpenclawCli && openclawIsAbsent()) { + console.log(`[catalog] skipping ${provider}: the openclaw CLI is not present on this edition`); + return; + } + refreshing.add(provider); let fetcher: Promise; diff --git a/src/app/setup-api/local-ai/route.ts b/src/app/setup-api/local-ai/route.ts index f956a740c..5792272e0 100644 --- a/src/app/setup-api/local-ai/route.ts +++ b/src/app/setup-api/local-ai/route.ts @@ -5,7 +5,7 @@ import { promisify } from "util"; import { execFile as execFileCb } from "child_process"; import { setMany } from "@/lib/config-store"; import { stopLocalAiProvider } from "@/lib/local-ai-runtime"; -import { readConfig as readOpenClawConfig, inferConfiguredLocalModel, findOpenclawBin, restartGateway } from "@/lib/openclaw-config"; +import { readConfig as readOpenClawConfig, inferConfiguredLocalModel, findOpenclawBin, restartGateway, openclawIsAbsent } from "@/lib/openclaw-config"; import { getActiveHarness } from "@/lib/harness"; import { removeLocalAiFromHermes } from "@/lib/hermes-local-ai"; @@ -47,13 +47,18 @@ export async function POST(request: Request) { local_ai_configured_at: undefined, }); - await runCommand(OPENCLAW_BIN, [ - "config", - "set", - "agents.defaults.model.fallbacks", - JSON.stringify([]), - "--json", - ]).catch(() => {}); + // Clearing the OpenClaw fallback only applies where OpenClaw exists. On the + // Hermes SKU there is no binary to spawn (it would ENOENT); the Hermes + // unregister below is what actually takes effect there. + if (!openclawIsAbsent()) { + await runCommand(OPENCLAW_BIN, [ + "config", + "set", + "agents.defaults.model.fallbacks", + JSON.stringify([]), + "--json", + ]).catch(() => {}); + } // Hermes keeps its own providers block, so disabling here has to unregister // there too — otherwise the picker keeps offering a model that is no longer diff --git a/src/lib/openclaw-config.ts b/src/lib/openclaw-config.ts index ab7419ead..f5cbe43df 100644 --- a/src/lib/openclaw-config.ts +++ b/src/lib/openclaw-config.ts @@ -8,6 +8,32 @@ import { readEdition } from "@/lib/edition-source"; const exec = promisify(execFile); +/** + * Thrown when the OpenClaw CLI is asked to run on an edition that does not ship + * it. The Hermes SKU has no `openclaw` binary at all (see `openclawIsAbsent`), + * so a spawn would fail deep inside a request with a raw `spawn openclaw + * ENOENT` — a confusing, un-actionable error. Callers that have a Hermes-native + * equivalent should route to it *before* reaching the CLI; this typed error is + * the backstop for the paths that don't, so they can fail cleanly and honestly + * instead of blaming the user's credentials. + */ +export class OpenclawUnavailableError extends Error { + constructor(message = "The OpenClaw CLI is not available on this edition.") { + super(message); + this.name = "OpenclawUnavailableError"; + } +} + +/** + * True when this device ships no `openclaw` binary — i.e. the Hermes edition. + * openclaw is present on the `openclaw` and `dual` SKUs; only `hermes` removes + * it. Keyed on the edition (a root-owned env read) rather than a filesystem + * probe so it is synchronous and cannot be spoofed by a user-writable path. + */ +export function openclawIsAbsent(): boolean { + return readEdition() === "hermes"; +} + /** * Options for {@link runOpenclawConfigSet}. */ @@ -107,6 +133,13 @@ interface SpawnOpenclawOptions { * it carries the ConfigMutationConflictError signature used for retry. */ function spawnOpenclaw(args: string[], options: SpawnOpenclawOptions = {}): Promise { + // Chokepoint guard: on an edition with no openclaw binary, refuse with a + // typed error rather than spawn the bare `"openclaw"` fallback (findOpenclawBin + // returns that string when no real path resolves) and surface a raw ENOENT + // from inside a request. Every config-set / pairing call funnels through here. + if (openclawIsAbsent()) { + return Promise.reject(new OpenclawUnavailableError()); + } const bin = findOpenclawBin(); const { uid, gid, captureStdout = false } = options; const timeoutMs = options.timeoutMs ?? 30_000; diff --git a/src/lib/updater.ts b/src/lib/updater.ts index 763bffdff..aa3757050 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -3,7 +3,7 @@ import { promisify } from "util"; import { readFile } from "fs/promises"; import path from "path"; import { get, set, setMany } from "./config-store"; -import { findOpenclawBin, restartGateway } from "./openclaw-config"; +import { findOpenclawBin, restartGateway, openclawIsAbsent } from "./openclaw-config"; import { isPortOpen } from "./port-probe"; const PROJECT_DIR = "/home/clawbox/clawbox"; @@ -309,6 +309,8 @@ async function waitForGateway(timeoutMs: number): Promise { } async function runOpenclawDoctorFix(): Promise { + // No openclaw binary on the Hermes edition — nothing to doctor. + if (openclawIsAbsent()) return; try { await execFile(OPENCLAW_BIN, ["doctor", "--fix", "--yes", "--non-interactive"], { timeout: 90_000, @@ -626,10 +628,15 @@ export async function getVersionInfo(): Promise { const gitCmd = `git -c safe.directory=${PROJECT_DIR} -C ${PROJECT_DIR}`; const [targetVersion, openclawCurrent, openclawTarget, rawVersion] = await Promise.all([ getTargetVersion(), - execFile(OPENCLAW_BIN, ["--version"], { timeout: 10_000 }) - .then(({ stdout }) => stdout.trim() || null) - // Fallback: read version from the installed package.json - .catch(() => readPkgVersion(OPENCLAW_PKG)), + // The Hermes edition ships no openclaw binary, so skip the spawn and read + // the version from the installed package.json (absent there too → null, + // which is correct: there is no OpenClaw version to report on Hermes). + openclawIsAbsent() + ? readPkgVersion(OPENCLAW_PKG) + : execFile(OPENCLAW_BIN, ["--version"], { timeout: 10_000 }) + .then(({ stdout }) => stdout.trim() || null) + // Fallback: read version from the installed package.json + .catch(() => readPkgVersion(OPENCLAW_PKG)), // Read the ClawBox-pinned target — NOT npm's latest. The pin file is // the canonical source for which OpenClaw the fleet should converge on. // Env override (`OPENCLAW_PIN_VERSION`) mirrors install.sh for QA flows. diff --git a/src/tests/routes/ai-models/catalog-edition.test.ts b/src/tests/routes/ai-models/catalog-edition.test.ts new file mode 100644 index 000000000..5a02e846d --- /dev/null +++ b/src/tests/routes/ai-models/catalog-edition.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as childProcess from "child_process"; + +// The model catalog for cloud providers comes from `openclaw models list`. On an +// edition with no openclaw binary (Hermes) that spawn is a guaranteed ENOENT, so +// the refresh must skip it cleanly rather than fork a missing binary once per +// provider on every boot warmup. + +vi.mock("child_process", () => ({ spawn: vi.fn() })); + +const mockOpenclawIsAbsent = vi.fn(); +vi.mock("@/lib/openclaw-config", () => ({ + findOpenclawBin: () => "openclaw", + openclawIsAbsent: () => mockOpenclawIsAbsent(), +})); + +vi.mock("@/lib/config-store", () => ({ DATA_DIR: "/tmp/clawbox-catalog-edition-test" })); + +import { refreshInBackground } from "@/app/setup-api/ai-models/catalog/route"; + +const mockSpawn = vi.mocked(childProcess.spawn); + +describe("catalog refresh — edition guard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not spawn openclaw for a CLI-backed provider on the hermes edition", () => { + mockOpenclawIsAbsent.mockReturnValue(true); + refreshInBackground("anthropic"); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("spawns openclaw for a CLI-backed provider when the binary is present", () => { + mockOpenclawIsAbsent.mockReturnValue(false); + // A distinct provider from the hermes test so the single-flight `refreshing` + // set can't mask the spawn. + refreshInBackground("openai"); + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tests/unit/openclaw-config.test.ts b/src/tests/unit/openclaw-config.test.ts index bb4eb55e7..a60dc94cc 100644 --- a/src/tests/unit/openclaw-config.test.ts +++ b/src/tests/unit/openclaw-config.test.ts @@ -101,6 +101,33 @@ describe("openclaw-config", () => { vi.clearAllMocks(); }); + describe("edition guard (openclawIsAbsent / spawn chokepoint)", () => { + const savedEdition = process.env.CLAWBOX_EDITION; + afterEach(() => { + if (savedEdition === undefined) delete process.env.CLAWBOX_EDITION; + else process.env.CLAWBOX_EDITION = savedEdition; + }); + + it("reports the openclaw binary as absent on the hermes edition", () => { + process.env.CLAWBOX_EDITION = "hermes"; + expect(openclawConfig.openclawIsAbsent()).toBe(true); + }); + + it("reports the openclaw binary as present on the openclaw and dual editions", () => { + process.env.CLAWBOX_EDITION = "openclaw"; + expect(openclawConfig.openclawIsAbsent()).toBe(false); + process.env.CLAWBOX_EDITION = "dual"; + expect(openclawConfig.openclawIsAbsent()).toBe(false); + }); + + it("refuses `openclaw config set` with a typed error on hermes instead of spawning", async () => { + process.env.CLAWBOX_EDITION = "hermes"; + await expect( + openclawConfig.runOpenclawConfigSet(["model.provider", "clawai"]), + ).rejects.toBeInstanceOf(openclawConfig.OpenclawUnavailableError); + }); + }); + describe("ensureCompactionReserveFloor", () => { it("writes the default reserve floor when compaction config is missing", async () => { mockFs.readFile.mockResolvedValueOnce(JSON.stringify({ agents: { defaults: {} } }) as never); From ad01000b48a511344aaae80941df623ec4129492 Mon Sep 17 00:00:00 2001 From: KrasimirKralev Date: Tue, 11 Aug 2026 11:04:16 +0300 Subject: [PATCH 3/4] fix(ai-models): configure providers through Hermes on the hermes edition The configure route wrote OpenClaw config and shelled out to `openclaw config set` for every provider, so on Hermes the local-model (Gemma) switch and API-key providers failed with a spawn ENOENT and then told the user to check credentials that a local model does not have. On the hermes edition, route local models to applyLocalAiToHermes, ClawBox AI to applyClawaiToHermes, and API-key cloud providers to the Hermes credential store, and classify the catch-all error so a local or edition failure never reports a credential problem. --- .../setup-api/ai-models/configure/route.ts | 99 +++++++- src/lib/hermes-cloud-provider.ts | 132 +++++++++++ .../routes/ai-models/configure-hermes.test.ts | 221 ++++++++++++++++++ src/tests/routes/ai-models/configure.test.ts | 5 + src/tests/routes/local-ai.test.ts | 3 + 5 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 src/lib/hermes-cloud-provider.ts create mode 100644 src/tests/routes/ai-models/configure-hermes.test.ts diff --git a/src/app/setup-api/ai-models/configure/route.ts b/src/app/setup-api/ai-models/configure/route.ts index 445ad45ca..effdf5181 100644 --- a/src/app/setup-api/ai-models/configure/route.ts +++ b/src/app/setup-api/ai-models/configure/route.ts @@ -15,9 +15,13 @@ import { applyModelOverrideToAllAgentSessions, parseFullyQualifiedModel, setProviderPlugins, + openclawIsAbsent, + OpenclawUnavailableError, } from "@/lib/openclaw-config"; import { getActiveHarness } from "@/lib/harness"; -import { applyLocalAiToHermes } from "@/lib/hermes-local-ai"; +import { applyLocalAiToHermes, HermesLocalApplyError } from "@/lib/hermes-local-ai"; +import { applyClawaiToHermes, ClawaiApplyError } from "@/lib/hermes-clawai"; +import { applyCloudProviderKeyToHermes, HermesCloudApplyError } from "@/lib/hermes-cloud-provider"; import { getDefaultLlamaCppModel, getLlamaCppContextWindow, @@ -436,6 +440,11 @@ async function writeOpenAICompatProvider(opts: { } export async function POST(request: Request) { + // Hoisted so the catch can classify the failure without re-parsing the body — + // a local-model or wrong-edition failure must not be reported as a credential + // problem (there is no credential to check). + let requestProvider: string | undefined; + let requestScope: ConfigureScope = "primary"; try { let body: { provider?: string; @@ -505,6 +514,8 @@ export async function POST(request: Request) { } const { provider, apiKey, authMode = "token", idToken, refreshToken, expiresIn, projectId, scope = "primary", model: bodyModel } = body; + requestProvider = provider; + requestScope = scope; const requestedClawboxAiTier = normalizeClawboxAiTier(body.clawaiTier); const normalizedApiKey = typeof apiKey === "string" ? apiKey.trim() : ""; const isOllama = provider === "ollama"; @@ -681,6 +692,73 @@ export async function POST(request: Request) { } } + // ── Hermes edition: no openclaw binary exists here ────────────────────── + // Everything below configures OpenClaw — it writes ~/.openclaw, runs + // `openclaw config set …`, and restarts the gateway. On the Hermes SKU there + // is no openclaw binary, so the API-key path used to fail with `spawn + // openclaw ENOENT` and then blame the user's credentials, while the local + // (Gemma) switch failed the same way. Route to Hermes' own config store + // instead — the same one the OAuth/token path already writes. + if (openclawIsAbsent()) { + try { + if (isLocalScope && (isLlamaCpp || isOllama)) { + // On-device model (Gemma via llama.cpp, or Ollama): persist the same + // config-store keys the OpenClaw path would, then register it with + // Hermes as an available provider (activating it only on a fresh + // device, matching shouldPromoteLocalToPrimary). No gateway restart. + await setMany({ + local_ai_configured: true, + local_ai_provider: ocProvider, + local_ai_model: config.defaultModel, + local_ai_configured_at: new Date().toISOString(), + }); + await applyLocalAiToHermes({ + provider: ocProvider as "llamacpp" | "ollama", + // Hermes wants the bare model id, not the `llamacpp/…` qualified form. + model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""), + makeDefault: shouldPromoteLocalToPrimary, + }); + return NextResponse.json({ success: true }); + } + if (isClawAI) { + await applyClawaiToHermes(clawboxAiToken, resolvedClawboxTier ?? CLAWBOX_AI_DEFAULT_TIER); + return NextResponse.json({ success: true }); + } + if (authMode !== "subscription" && normalizedApiKey) { + // Cloud API-key providers Hermes supports (anthropic, google→gemini, + // openrouter). The credential lands in Hermes' own store and the + // provider is activated through Hermes' catalog. + const result = await applyCloudProviderKeyToHermes({ + openclawProvider: provider, + apiKey: normalizedApiKey, + }); + if (result.activated) { + return NextResponse.json({ success: true }); + } + return NextResponse.json( + { error: "Key saved. Open the Hermes provider panel to pick a model for it." }, + { status: 409 }, + ); + } + // OAuth / subscription providers (and OpenAI, which is OAuth-only on + // Hermes) sign in through the Hermes provider panel, not this route. + return NextResponse.json( + { error: "This provider is set up through the Hermes provider panel on this edition." }, + { status: 400 }, + ); + } catch (err) { + if ( + err instanceof HermesCloudApplyError + || err instanceof HermesLocalApplyError + || err instanceof ClawaiApplyError + ) { + // Author-controlled, non-credential message — safe to echo. + return NextResponse.json({ error: err.message }, { status: 502 }); + } + throw err; // unexpected — fall to the outer catch, which classifies it + } + } + // 1. Write token to auth-profiles.json // // ── AUDIT: schema-drift risk ─────────────────────────────────── @@ -1128,9 +1206,20 @@ export async function POST(request: Request) { // paths. Log it server-side for diagnosis and return a generic, actionable // message (mirrors the sanitized gateway-restart branch above). console.error("[configure] Failed to configure AI model:", err instanceof Error ? err.message : err); - return NextResponse.json( - { error: "Failed to configure AI model. Please check your credentials and try again." }, - { status: 500 } - ); + // Classify so the message matches the cause. A local on-device model has no + // credentials, and an edition without the openclaw binary is not something + // the user can fix by re-checking a key — "check your credentials" is wrong + // and un-actionable for both. + const isLocalRequest = + requestScope === "local" || requestProvider === "ollama" || requestProvider === "llamacpp"; + let message: string; + if (err instanceof OpenclawUnavailableError) { + message = "This action isn't available on this edition."; + } else if (isLocalRequest) { + message = "Couldn't set up the on-device model. Please try again."; + } else { + message = "Failed to configure AI model. Please check your credentials and try again."; + } + return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/src/lib/hermes-cloud-provider.ts b/src/lib/hermes-cloud-provider.ts new file mode 100644 index 000000000..ba07a4b2f --- /dev/null +++ b/src/lib/hermes-cloud-provider.ts @@ -0,0 +1,132 @@ +import { runHermesCli } from "@/lib/hermes-cli"; +import { setMany } from "@/lib/config-store"; +import { + getModelOptions, + invalidateModelOptions, + isAllowedProvider, + scopeFromPayload, +} from "@/lib/hermes-model-options"; + +// Applying an API-key cloud provider to a HERMES device. +// +// On the Hermes SKU there is no `openclaw` binary, so the OpenClaw configure +// route cannot shell out to it to store a provider key — that is exactly what +// produced `spawn openclaw ENOENT` for an API-key provider (Anthropic) while the +// OAuth path succeeded. Hermes keeps its own credential store, so the key is +// written the same way `/setup-api/hermes/provider-key` does — `hermes auth add` +// — and the provider is then activated through Hermes' own model catalog, the +// same pairing-safe way `/setup-api/hermes/models` selects a provider. + +export class HermesCloudApplyError extends Error { + constructor(message: string) { + super(message); + this.name = "HermesCloudApplyError"; + } +} + +// OpenClaw provider id -> Hermes provider slug, for the API-key providers Hermes +// configures with `hermes auth add ... --type api-key`. Kept in step with the +// allowlist in `/setup-api/hermes/provider-key`. `openai` is intentionally +// absent: on Hermes it authenticates via OAuth (`openai-codex`), not a pasted +// key, so an OpenAI API key has no Hermes home and must not silently no-op. +const OPENCLAW_TO_HERMES_KEY_PROVIDER: Readonly> = { + anthropic: "anthropic", + google: "gemini", + openrouter: "openrouter", +}; + +/** The Hermes slug an OpenClaw API-key provider maps to, or null when Hermes + * has no API-key home for it (e.g. openai, which is OAuth-only on Hermes). */ +export function hermesKeyProviderFor(openclawProvider: string): string | null { + return OPENCLAW_TO_HERMES_KEY_PROVIDER[openclawProvider] ?? null; +} + +export interface HermesCloudApplyResult { + provider: string; + /** The model that was activated, or "" when the key was stored but no model + * could be resolved yet (the provider is left unchanged in that case). */ + model: string; + /** True when the provider was made the active one; false when only the key + * was stored (no model available to pair with it yet). */ + activated: boolean; +} + +/** + * Store an API key for a cloud provider on Hermes and, when a model can be + * resolved for it, make it the active provider. + * + * @throws HermesCloudApplyError with only ClawBox-authored text — a raw hermes + * stderr can carry the binary path, and the key must never be echoed back. + */ +export async function applyCloudProviderKeyToHermes(opts: { + openclawProvider: string; + apiKey: string; +}): Promise { + const slug = hermesKeyProviderFor(opts.openclawProvider); + if (!slug) { + throw new HermesCloudApplyError( + "This provider is set up through the Hermes provider panel on this edition.", + ); + } + const key = opts.apiKey.trim(); + // A leading "-" would be read by hermes as a flag; runHermesCli never uses a + // shell, but argv position still matters. + if (!key || key.startsWith("-")) { + throw new HermesCloudApplyError("An API key is required."); + } + + // 1. Store the credential — this flips the provider's `authenticated` flag and + // unlocks its model list, so it must land before we try to pick a model. + const add = await runHermesCli( + ["auth", "add", slug, "--type", "api-key", "--api-key", key], + { timeoutMs: 20_000 }, + ); + if (add.code !== 0) { + // Never echo the key. hermes stderr may name the provider but not the secret. + throw new HermesCloudApplyError(add.stderr || "Failed to save the API key."); + } + invalidateModelOptions(); + + // 2. Resolve a valid default model for the now-credentialed provider. + let model = ""; + try { + const payload = await getModelOptions({ refresh: true }); + if (isAllowedProvider(payload, slug)) { + model = (await scopeFromPayload(payload, slug)).defaultModel || ""; + } + } catch { + // Catalog unreachable — treat as "no model yet" and leave the provider be. + } + + // Writing model.provider without a model.default would strand the config on a + // provider with no model (hermes config set can't clear model.default + // atomically). So only activate when we actually have a model to pair; the key + // is already stored either way, so the customer can finish in the Hermes panel. + if (!model) { + // The key is already stored in Hermes' own credential store; leave the + // active provider untouched so the device isn't stranded on a model-less + // provider. The customer finishes by picking a model in the Hermes panel. + return { provider: slug, model: "", activated: false }; + } + + const setProvider = await runHermesCli(["config", "set", "model.provider", slug], { timeoutMs: 15_000 }); + if (setProvider.code !== 0) { + throw new HermesCloudApplyError(setProvider.stderr || "Failed to select the provider."); + } + const setModel = await runHermesCli(["config", "set", "model.default", model], { timeoutMs: 15_000 }); + if (setModel.code !== 0) { + throw new HermesCloudApplyError(setModel.stderr || "Failed to set the model."); + } + + // Keep the wizard's own status route consistent: without ai_model_configured + // the setup flow can't advance past the AI step on a Hermes device. + await setMany({ + ai_model_configured: true, + ai_model_provider: slug, + ai_model_configured_at: new Date().toISOString(), + }); + + // The device's provider/model just changed — don't serve the old selection. + invalidateModelOptions(); + return { provider: slug, model, activated: true }; +} diff --git a/src/tests/routes/ai-models/configure-hermes.test.ts b/src/tests/routes/ai-models/configure-hermes.test.ts new file mode 100644 index 000000000..1aaaf794c --- /dev/null +++ b/src/tests/routes/ai-models/configure-hermes.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import * as childProcess from "child_process"; + +// The Hermes edition ships no `openclaw` binary. This suite pins that the +// configure route configures the device through Hermes' own config instead of +// shelling out to a binary that cannot exist — the fix for both the local-model +// (Gemma) switch and the API-key path that used to fail with `spawn openclaw +// ENOENT` and then blame the user's credentials. + +vi.mock("child_process", () => ({ + execFile: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + rename: vi.fn(), + chown: vi.fn(), + mkdir: vi.fn(), + rm: vi.fn(), + unlink: vi.fn(), + }, +})); + +vi.mock("@/lib/config-store", () => ({ + DATA_DIR: "/home/clawbox/clawbox/data", + getAll: vi.fn(), + setMany: vi.fn(), +})); + +vi.mock("@/lib/openclaw-config", () => ({ + DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR: 24000, + compactionReserveFloorForContext: (n: number) => + Number.isFinite(n) && n > 0 ? Math.min(24000, Math.max(4096, Math.round(n / 4))) : 24000, + restartGateway: vi.fn(), + findOpenclawBin: vi.fn().mockReturnValue("/usr/local/bin/openclaw"), + readConfig: vi.fn(), + inferConfiguredLocalModel: vi.fn(), + runOpenclawConfigSet: vi.fn(), + applyModelOverrideToAllAgentSessions: vi.fn().mockResolvedValue(undefined), + parseFullyQualifiedModel: vi.fn((fq: string) => { + const i = fq.indexOf("/"); + return i <= 0 || i === fq.length - 1 ? null : { provider: fq.slice(0, i), modelId: fq.slice(i + 1) }; + }), + setProviderPlugins: vi.fn().mockResolvedValue(undefined), + openclawIsAbsent: vi.fn(), + OpenclawUnavailableError: class OpenclawUnavailableError extends Error {}, +})); + +vi.mock("@/lib/harness", () => ({ + getActiveHarness: vi.fn().mockResolvedValue("hermes"), +})); + +// Error classes live inside the (hoisted) factories and are re-imported per test +// from the same post-reset registry as the route, so `instanceof` matches. +vi.mock("@/lib/hermes-local-ai", () => ({ + applyLocalAiToHermes: vi.fn(), + HermesLocalApplyError: class HermesLocalApplyError extends Error {}, +})); + +vi.mock("@/lib/hermes-clawai", () => ({ + applyClawaiToHermes: vi.fn(), + ClawaiApplyError: class ClawaiApplyError extends Error {}, +})); + +vi.mock("@/lib/hermes-cloud-provider", () => ({ + applyCloudProviderKeyToHermes: vi.fn(), + HermesCloudApplyError: class HermesCloudApplyError extends Error {}, +})); + +vi.mock("@/lib/llamacpp", () => ({ + getDefaultLlamaCppModel: vi.fn().mockReturnValue("gemma4-e2b-it-q4_0"), + getLlamaCppContextWindow: vi.fn().mockReturnValue(131072), + getLlamaCppMaxTokens: vi.fn().mockReturnValue(131072), + getLlamaCppProxyBaseUrl: vi.fn().mockReturnValue("http://127.0.0.1/setup-api/local-ai/llamacpp/v1"), +})); + +vi.mock("@/lib/local-ai-runtime", () => ({ + getLocalAiProxyBaseUrl: vi.fn((p: string) => `http://127.0.0.1/setup-api/local-ai/${p}`), +})); + +vi.mock("@/lib/local-ai-token", () => ({ + getLocalAiToken: vi.fn().mockReturnValue("a".repeat(64)), + markLocalAiTokenMigrated: vi.fn(), +})); + +vi.mock("@/lib/clawkeep", () => ({ unpairLocal: vi.fn() })); +vi.mock("@/lib/gateway-proxy", () => ({ getOrGenerateGatewayToken: vi.fn().mockResolvedValue("tok") })); +vi.mock("@/lib/codex-model-probe", () => ({ resolveEntitledCodexModel: vi.fn().mockResolvedValue(null) })); +vi.mock("@/app/setup-api/ai-models/catalog/route", () => ({ refreshInBackground: vi.fn() })); + +const mockSpawn = vi.mocked(childProcess.spawn); + +function jsonRequest(body: unknown): Request { + return new Request("http://localhost/setup-api/ai-models/configure", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /setup-api/ai-models/configure — hermes edition", () => { + let POST: (req: Request) => Promise; + let mockSetMany: ReturnType; + let mockRunOpenclawConfigSet: ReturnType; + let mockRestartGateway: ReturnType; + let mockApplyLocalAiToHermes: ReturnType; + let mockApplyClawaiToHermes: ReturnType; + let mockApplyCloudProviderKeyToHermes: ReturnType; + let HermesLocalApplyError: new (msg?: string) => Error; + let HermesCloudApplyError: new (msg?: string) => Error; + + beforeEach(async () => { + vi.resetModules(); + + // Resolve every handle (and the error classes) from the SAME post-reset + // module registry the route will import, so mock returns survive the config's + // mockReset and `instanceof` in the route's catch blocks matches. + const oc = await import("@/lib/openclaw-config"); + vi.mocked(oc.openclawIsAbsent).mockReturnValue(true); + mockRunOpenclawConfigSet = vi.mocked(oc.runOpenclawConfigSet) as unknown as ReturnType; + mockRestartGateway = vi.mocked(oc.restartGateway) as unknown as ReturnType; + + const cs = await import("@/lib/config-store"); + vi.mocked(cs.getAll).mockResolvedValue({}); + mockSetMany = vi.mocked(cs.setMany) as unknown as ReturnType; + mockSetMany.mockResolvedValue(undefined); + + const localMod = await import("@/lib/hermes-local-ai"); + HermesLocalApplyError = localMod.HermesLocalApplyError as new (msg?: string) => Error; + mockApplyLocalAiToHermes = vi.mocked(localMod.applyLocalAiToHermes) as unknown as ReturnType; + mockApplyLocalAiToHermes.mockResolvedValue({ provider: "clawlocal", model: "gemma4-e2b-it-q4_0" }); + + const clawaiMod = await import("@/lib/hermes-clawai"); + mockApplyClawaiToHermes = vi.mocked(clawaiMod.applyClawaiToHermes) as unknown as ReturnType; + mockApplyClawaiToHermes.mockResolvedValue({ provider: "clawai", model: "deepseek-v4-flash", tier: "flash" }); + + const cloudMod = await import("@/lib/hermes-cloud-provider"); + HermesCloudApplyError = cloudMod.HermesCloudApplyError as new (msg?: string) => Error; + mockApplyCloudProviderKeyToHermes = vi.mocked(cloudMod.applyCloudProviderKeyToHermes) as unknown as ReturnType; + mockApplyCloudProviderKeyToHermes.mockResolvedValue({ provider: "anthropic", model: "claude-sonnet-4-6", activated: true }); + + ({ POST } = await import("@/app/setup-api/ai-models/configure/route")); + }); + + /** Assert that nothing in this call reached the OpenClaw CLI. */ + function expectNoOpenclawSpawn() { + expect(mockRunOpenclawConfigSet).not.toHaveBeenCalled(); + expect(mockRestartGateway).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + } + + it("switches the local model (Gemma) through Hermes without spawning openclaw", async () => { + const res = await POST(jsonRequest({ provider: "llamacpp", scope: "local" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockApplyLocalAiToHermes).toHaveBeenCalledWith( + expect.objectContaining({ provider: "llamacpp", model: "gemma4-e2b-it-q4_0", makeDefault: true }), + ); + expect(mockSetMany).toHaveBeenCalledWith(expect.objectContaining({ + local_ai_configured: true, + local_ai_provider: "llamacpp", + })); + expectNoOpenclawSpawn(); + }); + + it("configures an API-key cloud provider (Anthropic) through Hermes, not openclaw", async () => { + const res = await POST(jsonRequest({ provider: "anthropic", apiKey: "sk-ant-test", authMode: "token" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockApplyCloudProviderKeyToHermes).toHaveBeenCalledWith({ + openclawProvider: "anthropic", + apiKey: "sk-ant-test", + }); + expectNoOpenclawSpawn(); + }); + + it("configures ClawBox AI through Hermes, not openclaw", async () => { + const cs = await import("@/lib/config-store"); + vi.mocked(cs.getAll).mockResolvedValue({ clawai_token: "claw_abc123def456" }); + + const res = await POST(jsonRequest({ provider: "clawai", clawaiTier: "flash" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockApplyClawaiToHermes).toHaveBeenCalled(); + expectNoOpenclawSpawn(); + }); + + it("does not blame credentials when the local-model setup fails on Hermes", async () => { + mockApplyLocalAiToHermes.mockRejectedValue(new HermesLocalApplyError("local model id is malformed")); + + const res = await POST(jsonRequest({ provider: "llamacpp", scope: "local" })); + const body = await res.json(); + + expect(res.ok).toBe(false); + expect(body.error).toBe("local model id is malformed"); + expect(String(body.error).toLowerCase()).not.toContain("credential"); + expectNoOpenclawSpawn(); + }); + + it("returns an honest, non-credential message for a provider with no Hermes API-key home", async () => { + mockApplyCloudProviderKeyToHermes.mockRejectedValue( + new HermesCloudApplyError("This provider is set up through the Hermes provider panel on this edition."), + ); + + const res = await POST(jsonRequest({ provider: "google", apiKey: "AIzaTest", authMode: "token" })); + const body = await res.json(); + + expect(res.ok).toBe(false); + expect(String(body.error).toLowerCase()).not.toContain("credential"); + expectNoOpenclawSpawn(); + }); +}); diff --git a/src/tests/routes/ai-models/configure.test.ts b/src/tests/routes/ai-models/configure.test.ts index 836988862..bff8f6058 100644 --- a/src/tests/routes/ai-models/configure.test.ts +++ b/src/tests/routes/ai-models/configure.test.ts @@ -68,6 +68,11 @@ vi.mock("@/lib/openclaw-config", () => ({ // based on the active provider. Tests don't care about the side effect; just // make the import resolve. setProviderPlugins: vi.fn().mockResolvedValue(undefined), + // Edition guard: these tests exercise the OpenClaw path, so openclaw is + // present. The Hermes branch (openclawIsAbsent → true) is covered separately + // in configure-hermes.test.ts. + openclawIsAbsent: vi.fn().mockReturnValue(false), + OpenclawUnavailableError: class OpenclawUnavailableError extends Error {}, })); // llamacpp / local-ai-runtime have pure getters, but local-ai-runtime diff --git a/src/tests/routes/local-ai.test.ts b/src/tests/routes/local-ai.test.ts index a4b3d3847..98e49acf7 100644 --- a/src/tests/routes/local-ai.test.ts +++ b/src/tests/routes/local-ai.test.ts @@ -14,6 +14,9 @@ vi.mock("@/lib/openclaw-config", () => ({ inferConfiguredLocalModel: vi.fn(), readConfig: vi.fn(), restartGateway: vi.fn(), + // Default to "openclaw present". The Hermes-edition disable path is asserted + // in its own test below by flipping this to true. + openclawIsAbsent: vi.fn().mockReturnValue(false), })); vi.mock("@/lib/local-ai-runtime", () => ({ From 8c82c7255fa3149635c35fcf0bbafa5337c49baf Mon Sep 17 00:00:00 2001 From: KrasimirKralev Date: Tue, 11 Aug 2026 11:04:26 +0300 Subject: [PATCH 4/4] fix(clawkeep): gate cloud backup honestly on editions without OpenClaw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClawKeep archives the OpenClaw agent through the openclaw CLI, so on the Hermes SKU it cannot run — yet it advised installing OpenClaw (`npm install -g openclaw`), which contradicts the edition. Surface supportedOnEdition from getStatus and, when false, show an honest "not available on this edition" card instead of the install instructions, with new copy in all ten locales. --- src/components/ClawKeepApp.tsx | 35 +++++++++++++++++ src/lib/clawkeep-translations.ts | 20 ++++++++++ src/lib/clawkeep.ts | 10 +++++ src/tests/unit/clawkeep-edition.test.ts | 50 +++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 src/tests/unit/clawkeep-edition.test.ts diff --git a/src/components/ClawKeepApp.tsx b/src/components/ClawKeepApp.tsx index 96da10538..96533a14f 100644 --- a/src/components/ClawKeepApp.tsx +++ b/src/components/ClawKeepApp.tsx @@ -29,6 +29,8 @@ interface ClawKeepStatus { uploadStartedAtMs: number; openclawInstalled: boolean; daemonInstalled: boolean; + /** False on an edition with no OpenClaw to back up (Hermes). */ + supportedOnEdition?: boolean; schedule: ClawKeepSchedule; nextRunAtMs: number; /** True when the device has a stored backup-encryption passphrase. The @@ -497,6 +499,22 @@ export default function ClawKeepApp() { ); } + // ClawKeep archives the OpenClaw agent through the openclaw CLI. On an edition + // that ships no OpenClaw (Hermes) the feature genuinely cannot run, so say so + // honestly and stop here — rather than dropping to the setup card that told + // the user to `npm install -g openclaw`, which would contradict this SKU. + if (status.supportedOnEdition === false) { + return ( +
+
+
+ +
+
+
+ ); + } + return (
@@ -1448,6 +1466,23 @@ function DashboardCard({ ); } +function EditionUnavailableCard() { + const { t } = useT(); + return ( +
+
+ +

{t("clawkeep.edition.unsupportedTitle")}

+
+

+ {t("clawkeep.edition.unsupportedBody")} +

+
+ ); +} + function SystemCard({ status }: { status: ClawKeepStatus }) { const { t } = useT(); return ( diff --git a/src/lib/clawkeep-translations.ts b/src/lib/clawkeep-translations.ts index 922024fec..368cd2881 100644 --- a/src/lib/clawkeep-translations.ts +++ b/src/lib/clawkeep-translations.ts @@ -114,6 +114,8 @@ export const clawkeepTranslations: Record> = { // === System (setup-needed) card === "clawkeep.system.setupNeeded": "Setup needed", + "clawkeep.edition.unsupportedTitle": "Not available on this edition", + "clawkeep.edition.unsupportedBody": "ClawKeep backs up the OpenClaw agent, which this device doesn't run. Cloud backup isn't available on this edition.", "clawkeep.system.notOnPath": "is not on $PATH. Install with", "clawkeep.system.notOnPathFrom": "is not on $PATH. From", "clawkeep.system.run": "run", @@ -278,6 +280,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "Качване на криптирания архив в твоя префикс…", "clawkeep.step.checkingStats": "Проверка на облачната снимка…", "clawkeep.system.setupNeeded": "Нужна е настройка", + "clawkeep.edition.unsupportedTitle": "Не е налично за това издание", + "clawkeep.edition.unsupportedBody": "ClawKeep архивира агента OpenClaw, който това устройство не използва. Облачното архивиране не е налично за това издание.", "clawkeep.system.notOnPath": "не е в $PATH. Инсталирай с", "clawkeep.system.notOnPathFrom": "не е в $PATH. От", "clawkeep.system.run": "изпълни", @@ -434,6 +438,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "Lade verschlüsseltes Archiv in deinen Prefix hoch…", "clawkeep.step.checkingStats": "Cloud-Snapshot wird verifiziert…", "clawkeep.system.setupNeeded": "Einrichtung erforderlich", + "clawkeep.edition.unsupportedTitle": "In dieser Edition nicht verfügbar", + "clawkeep.edition.unsupportedBody": "ClawKeep sichert den OpenClaw-Agenten, den dieses Gerät nicht ausführt. Cloud-Backup ist in dieser Edition nicht verfügbar.", "clawkeep.system.notOnPath": "ist nicht im $PATH. Installiere mit", "clawkeep.system.notOnPathFrom": "ist nicht im $PATH. Aus", "clawkeep.system.run": "ausführen", @@ -590,6 +596,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "Subiendo archivo cifrado a tu prefijo…", "clawkeep.step.checkingStats": "Verificando instantánea en la nube…", "clawkeep.system.setupNeeded": "Se requiere configuración", + "clawkeep.edition.unsupportedTitle": "No disponible en esta edición", + "clawkeep.edition.unsupportedBody": "ClawKeep hace copias de seguridad del agente OpenClaw, que este dispositivo no ejecuta. La copia de seguridad en la nube no está disponible en esta edición.", "clawkeep.system.notOnPath": "no está en $PATH. Instala con", "clawkeep.system.notOnPathFrom": "no está en $PATH. Desde", "clawkeep.system.run": "ejecuta", @@ -746,6 +754,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "Envoi de l'archive chiffrée vers votre préfixe…", "clawkeep.step.checkingStats": "Vérification de l'instantané cloud…", "clawkeep.system.setupNeeded": "Configuration requise", + "clawkeep.edition.unsupportedTitle": "Indisponible sur cette édition", + "clawkeep.edition.unsupportedBody": "ClawKeep sauvegarde l'agent OpenClaw, que cet appareil n'exécute pas. La sauvegarde dans le cloud n'est pas disponible sur cette édition.", "clawkeep.system.notOnPath": "n'est pas dans $PATH. Installez avec", "clawkeep.system.notOnPathFrom": "n'est pas dans $PATH. Depuis", "clawkeep.system.run": "exécutez", @@ -902,6 +912,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "Caricamento archivio cifrato nel tuo prefisso…", "clawkeep.step.checkingStats": "Verifica snapshot cloud…", "clawkeep.system.setupNeeded": "Configurazione necessaria", + "clawkeep.edition.unsupportedTitle": "Non disponibile in questa edizione", + "clawkeep.edition.unsupportedBody": "ClawKeep esegue il backup dell'agente OpenClaw, che questo dispositivo non utilizza. Il backup nel cloud non è disponibile in questa edizione.", "clawkeep.system.notOnPath": "non è in $PATH. Installa con", "clawkeep.system.notOnPathFrom": "non è in $PATH. Da", "clawkeep.system.run": "esegui", @@ -1058,6 +1070,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "暗号化アーカイブを専用プレフィックスへアップロード中…", "clawkeep.step.checkingStats": "クラウドスナップショットを検証中…", "clawkeep.system.setupNeeded": "セットアップが必要", + "clawkeep.edition.unsupportedTitle": "このエディションでは利用できません", + "clawkeep.edition.unsupportedBody": "ClawKeep は OpenClaw エージェントをバックアップしますが、このデバイスは OpenClaw を実行していません。このエディションではクラウドバックアップを利用できません。", "clawkeep.system.notOnPath": "は $PATH にありません。インストール:", "clawkeep.system.notOnPathFrom": "は $PATH にありません。", "clawkeep.system.run": "で実行:", @@ -1214,6 +1228,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "Versleuteld archief naar je prefix uploaden…", "clawkeep.step.checkingStats": "Cloud-snapshot verifiëren…", "clawkeep.system.setupNeeded": "Configuratie nodig", + "clawkeep.edition.unsupportedTitle": "Niet beschikbaar in deze editie", + "clawkeep.edition.unsupportedBody": "ClawKeep maakt back-ups van de OpenClaw-agent, die dit apparaat niet draait. Cloudback-up is niet beschikbaar in deze editie.", "clawkeep.system.notOnPath": "staat niet in $PATH. Installeer met", "clawkeep.system.notOnPathFrom": "staat niet in $PATH. Vanuit", "clawkeep.system.run": "voer uit", @@ -1370,6 +1386,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "Laddar upp krypterat arkiv till ditt prefix…", "clawkeep.step.checkingStats": "Verifierar molnögonblicksbild…", "clawkeep.system.setupNeeded": "Inställning krävs", + "clawkeep.edition.unsupportedTitle": "Inte tillgängligt i den här utgåvan", + "clawkeep.edition.unsupportedBody": "ClawKeep säkerhetskopierar OpenClaw-agenten, som den här enheten inte kör. Molnsäkerhetskopiering är inte tillgänglig i den här utgåvan.", "clawkeep.system.notOnPath": "finns inte i $PATH. Installera med", "clawkeep.system.notOnPathFrom": "finns inte i $PATH. Från", "clawkeep.system.run": "kör", @@ -1526,6 +1544,8 @@ export const clawkeepTranslations: Record> = { "clawkeep.step.uploading": "正在将加密归档上传到您的前缀…", "clawkeep.step.checkingStats": "正在验证云快照…", "clawkeep.system.setupNeeded": "需要设置", + "clawkeep.edition.unsupportedTitle": "此版本不可用", + "clawkeep.edition.unsupportedBody": "ClawKeep 备份的是 OpenClaw 代理,而此设备并未运行它。此版本不支持云备份。", "clawkeep.system.notOnPath": "不在 $PATH 中。请使用以下命令安装:", "clawkeep.system.notOnPathFrom": "不在 $PATH 中。请从", "clawkeep.system.run": "运行", diff --git a/src/lib/clawkeep.ts b/src/lib/clawkeep.ts index 5845bad92..b20594eaf 100644 --- a/src/lib/clawkeep.ts +++ b/src/lib/clawkeep.ts @@ -24,6 +24,7 @@ import path from "node:path"; import { StringDecoder } from "node:string_decoder"; import { findOpenclawBin } from "@/lib/openclaw-config"; +import { getEdition } from "@/lib/harness"; export const CLAWKEEP_DATA_DIR = process.env.CLAWKEEP_DATA_DIR?.trim() || path.join(os.homedir(), ".clawkeep"); @@ -122,6 +123,12 @@ export interface ClawKeepStatus { uploadStartedAtMs: number; openclawInstalled: boolean; daemonInstalled: boolean; + /** False on an edition that ships no OpenClaw to back up (Hermes). ClawKeep + * archives the OpenClaw agent via the openclaw CLI, which that edition does + * not have — so the feature genuinely cannot run there, and the UI must say + * so honestly rather than print an `npm install -g openclaw` remedy that + * contradicts the SKU. */ + supportedOnEdition: boolean; /** True while a restore is mid-flight (download → verify → swap). */ restoring: boolean; /** Schedule for unattended backups. `enabled=false` disarms the in-process scheduler. */ @@ -643,6 +650,9 @@ export async function getStatus(): Promise { uploadStartedAtMs: stateRaw.upload_started_at_ms ?? 0, openclawInstalled, daemonInstalled: daemonBin !== null, + // ClawKeep backs up the OpenClaw agent; the Hermes SKU ships no openclaw, so + // the feature has nothing to archive there and is not supported. + supportedOnEdition: getEdition() !== "hermes", restoring, schedule, nextRunAtMs: computeNextRunMs(schedule, new Date()), diff --git a/src/tests/unit/clawkeep-edition.test.ts b/src/tests/unit/clawkeep-edition.test.ts new file mode 100644 index 000000000..e1d35124f --- /dev/null +++ b/src/tests/unit/clawkeep-edition.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +// ClawKeep archives the OpenClaw agent through the openclaw CLI, so on an +// edition that ships no OpenClaw (Hermes) the feature genuinely cannot run. +// getStatus must surface that honestly so the UI can say so instead of printing +// an `npm install -g openclaw` remedy that contradicts the SKU. + +vi.mock("@/lib/harness", () => ({ getEdition: vi.fn() })); + +import { getEdition } from "@/lib/harness"; +const mockGetEdition = vi.mocked(getEdition); + +const TEST_ROOT = path.join(os.tmpdir(), `clawbox-clawkeep-edition-${process.pid}-${Date.now()}`); +const DATA_DIR = path.join(TEST_ROOT, "clawkeep"); + +let clawkeep: typeof import("@/lib/clawkeep"); + +beforeAll(async () => { + process.env.CLAWKEEP_DATA_DIR = DATA_DIR; + await fs.mkdir(DATA_DIR, { recursive: true }); + clawkeep = await import("@/lib/clawkeep"); +}); + +afterAll(async () => { + delete process.env.CLAWKEEP_DATA_DIR; + await fs.rm(TEST_ROOT, { recursive: true, force: true }); +}); + +describe("clawkeep getStatus — supportedOnEdition", () => { + it("is false on the hermes edition", async () => { + mockGetEdition.mockReturnValue("hermes"); + const status = await clawkeep.getStatus(); + expect(status.supportedOnEdition).toBe(false); + }); + + it("is true on the openclaw edition", async () => { + mockGetEdition.mockReturnValue("openclaw"); + const status = await clawkeep.getStatus(); + expect(status.supportedOnEdition).toBe(true); + }); + + it("is true on the dual edition", async () => { + mockGetEdition.mockReturnValue("dual"); + const status = await clawkeep.getStatus(); + expect(status.supportedOnEdition).toBe(true); + }); +});