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/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/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/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/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/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/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/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/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: { 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", () => ({ 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); + }); +}); 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);