diff --git a/.env.example b/.env.example index 00357a9..8842418 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,17 @@ NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789 # Runtime gateway URL — takes effect on restart without a rebuild. # Use this instead of NEXT_PUBLIC_GATEWAY_URL when you want to change the -# gateway endpoint without re-running `npm run build`. Also used as a -# fallback when openclaw.json is not present. +# gateway endpoint without re-running `npm run build`. # CLAW3D_GATEWAY_URL=ws://localhost:18789 # CLAW3D_GATEWAY_TOKEN= +# Optional: tell Studio which backend that runtime gateway URL represents. +# Valid values: openclaw, hermes, demo, custom +# CLAW3D_GATEWAY_ADAPTER_TYPE=openclaw + + +# HERMES_API_URL=http://localhost:8642 +# HERMES_API_KEY=change-me-local-dev +# HERMES_MODEL=hermes-agent # Debug UI DEBUG=true @@ -30,10 +37,12 @@ DEBUG=true # HERMES_ADAPTER_PORT=18789 # HERMES_MODEL=hermes # HERMES_AGENT_NAME=Hermes +# If CLAW3D_GATEWAY_URL is unset, Studio can still detect this local adapter port. # Demo gateway (no OpenClaw or Hermes required) # Run `npm run demo-gateway` and connect Claw3D to ws://localhost:18789 # DEMO_ADAPTER_PORT=18789 +# If CLAW3D_GATEWAY_URL is unset, Studio can still detect this local adapter port. # Optional: voice features # ELEVENLABS_API_KEY= diff --git a/README.md b/README.md index b4e611a..3c9dba2 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,10 @@ Common environment variables: - `UPSTREAM_ALLOWLIST` restricts which upstream gateway hosts Studio may proxy to. Set this in production. - `CUSTOM_RUNTIME_ALLOWLIST` restricts which hosts `/api/runtime/custom` may fetch. If unset, it falls back to `UPSTREAM_ALLOWLIST`. - `NEXT_PUBLIC_GATEWAY_URL` provides the default upstream gateway URL when Studio settings are empty. **Note:** this is a build-time variable — changes require `npm run build` to take effect. -- `CLAW3D_GATEWAY_URL` and `CLAW3D_GATEWAY_TOKEN` provide a runtime alternative to `NEXT_PUBLIC_GATEWAY_URL` that takes effect on server restart without a rebuild. These are also used as a fallback when `openclaw.json` is not present. +- `CLAW3D_GATEWAY_URL` and `CLAW3D_GATEWAY_TOKEN` provide a runtime alternative to `NEXT_PUBLIC_GATEWAY_URL` that takes effect on server restart without a rebuild. +- `CLAW3D_GATEWAY_ADAPTER_TYPE` can pair with `CLAW3D_GATEWAY_URL` to mark those runtime defaults as `openclaw`, `hermes`, `demo`, or `custom`. +- If `CLAW3D_GATEWAY_URL` is not set, Studio can still surface local Hermes or demo adapter defaults from `HERMES_ADAPTER_PORT` / `DEMO_ADAPTER_PORT`. +- OpenClaw file defaults still come from `~/.openclaw/openclaw.json` when present. - `OPENCLAW_STATE_DIR` and `OPENCLAW_CONFIG_PATH` override the default OpenClaw paths. - `OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`, `OPENCLAW_GATEWAY_SSH_PORT`, and `OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING` support advanced gateway-host operations over SSH when needed. - `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`, and `ELEVENLABS_MODEL_ID` enable voice reply integration. diff --git a/server/hermes-gateway-adapter.js b/server/hermes-gateway-adapter.js index ddd1226..fe893a4 100644 --- a/server/hermes-gateway-adapter.js +++ b/server/hermes-gateway-adapter.js @@ -27,6 +27,35 @@ const fs = require("fs"); const path = require("path"); const { WebSocketServer } = require("ws"); +function loadDotenvFile(filePath) { + if (!fs.existsSync(filePath)) return; + const content = fs.readFileSync(filePath, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (!match) continue; + const [, key, rawValue] = match; + if (process.env[key] !== undefined) continue; + let value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} + +function loadRuntimeEnv() { + const cwd = process.cwd(); + loadDotenvFile(path.join(cwd, ".env.local")); + loadDotenvFile(path.join(cwd, ".env")); +} + +loadRuntimeEnv(); + const HERMES_API_URL = (process.env.HERMES_API_URL || "http://localhost:8642").replace(/\/$/, ""); const HERMES_API_KEY = process.env.HERMES_API_KEY || ""; const ADAPTER_PORT = parseInt(process.env.HERMES_ADAPTER_PORT || "18789", 10); @@ -227,7 +256,7 @@ function loadHistoryFromDisk() { } } } catch (err) { - console.warn("[hermes-adapter] Could not load history:", err.message); + console.warn("[hermes-adapter] Could not load history:", sanitizeErrorMessage(err)); } } @@ -242,7 +271,7 @@ function saveHistoryToDisk() { fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true }); fs.writeFileSync(HISTORY_FILE, JSON.stringify(data, null, 2), "utf8"); } catch (err) { - console.warn("[hermes-adapter] Could not save history:", err.message); + console.warn("[hermes-adapter] Could not save history:", sanitizeErrorMessage(err)); } }, 500); } @@ -261,6 +290,17 @@ function randomId() { return require("crypto").randomBytes(8).toString("hex"); } +function redactSecrets(value) { + if (typeof value !== "string" || !value) return value; + let redacted = value; + if (HERMES_API_KEY) { + redacted = redacted.split(HERMES_API_KEY).join("[REDACTED]"); + } + redacted = redacted.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]"); + redacted = redacted.replace(/\b\d{8,12}:[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]"); + return redacted; +} + // --------------------------------------------------------------------------- // Hermes HTTP API helpers // --------------------------------------------------------------------------- @@ -311,6 +351,12 @@ async function readJsonBody(res) { return JSON.parse(raw); } +function sanitizeErrorMessage(error) { + if (!error) return "Unknown error"; + if (typeof error === "string") return redactSecrets(error); + return redactSecrets(error.message || String(error)); +} + function extractOpenAiStyleError(payload, fallbackMessage) { if (payload && typeof payload === "object") { const message = @@ -607,8 +653,9 @@ async function execDelegateTask(args) { byAgent: [{ agentId: targetId, recent: [{ key: sessionKey, updatedAt: Date.now() }] }] } }, }); } catch (err) { - emitSub("error", { errorMessage: err.message }); - return JSON.stringify({ ok: false, error: err.message }); + const message = sanitizeErrorMessage(err); + emitSub("error", { errorMessage: message }); + return JSON.stringify({ ok: false, error: message }); } return JSON.stringify({ ok: true, agent_id: targetId, response: responseText }); @@ -962,7 +1009,7 @@ async function handleMethod(method, params, id, sendEvent) { byAgent: [{ agentId: sessionAgentId, recent: [{ key: sessionKey, updatedAt: Date.now() }] }] } } }); } } catch (err) { - if (!aborted) emitChat("error", { errorMessage: err.message || "Hermes API error" }); + if (!aborted) emitChat("error", { errorMessage: sanitizeErrorMessage(err) || "Hermes API error" }); else emitChat("aborted", {}); } finally { activeRuns.delete(runId); @@ -1134,7 +1181,7 @@ function startAdapter() { const wss = new WebSocketServer({ server: httpServer }); wss.on("error", (err) => { - if (err.code !== "EADDRINUSE") console.error("[hermes-adapter] Server error:", err.message); + if (err.code !== "EADDRINUSE") console.error("[hermes-adapter] Server error:", sanitizeErrorMessage(err)); }); wss.on("connection", (ws) => { @@ -1144,7 +1191,7 @@ function startAdapter() { const send = (frame) => { if (ws.readyState === ws.OPEN) { try { ws.send(JSON.stringify(frame)); } - catch (e) { console.error("[hermes-adapter] send error:", e.message); } + catch (e) { console.error("[hermes-adapter] send error:", sanitizeErrorMessage(e)); } } }; @@ -1197,14 +1244,15 @@ function startAdapter() { const response = await handleMethod(method, params, id, sendEventFn); send(response); } catch (err) { - console.error(`[hermes-adapter] Error handling ${method}:`, err.message); - send(resErr(id, "internal_error", err.message || "Internal error")); + const message = sanitizeErrorMessage(err); + console.error(`[hermes-adapter] Error handling ${method}:`, message); + send(resErr(id, "internal_error", message || "Internal error")); } }); ws.on("close", () => activeSendEventFns.delete(sendEventFn)); ws.on("error", (err) => { - console.error("[hermes-adapter] WebSocket error:", err.message); + console.error("[hermes-adapter] WebSocket error:", sanitizeErrorMessage(err)); activeSendEventFns.delete(sendEventFn); }); }); @@ -1221,7 +1269,7 @@ function startAdapter() { if (err.code === "EADDRINUSE") { console.error(`[hermes-adapter] Port ${ADAPTER_PORT} in use. Set HERMES_ADAPTER_PORT to change it.`); } else { - console.error("[hermes-adapter] Server error:", err.message); + console.error("[hermes-adapter] Server error:", sanitizeErrorMessage(err)); } process.exit(1); }); diff --git a/src/features/agents/components/inspect/AgentSettingsPanel.tsx b/src/features/agents/components/inspect/AgentSettingsPanel.tsx index 2b037ec..19345dd 100644 --- a/src/features/agents/components/inspect/AgentSettingsPanel.tsx +++ b/src/features/agents/components/inspect/AgentSettingsPanel.tsx @@ -25,6 +25,7 @@ import type { AgentState } from "@/features/agents/state/store"; import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPayloadBuilder"; import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types"; import type { SkillStatusReport } from "@/lib/skills/types"; +import type { StudioGatewayAdapterType } from "@/lib/studio/settings"; export type AgentSettingsPanelProps = { agent: AgentState; @@ -47,6 +48,7 @@ export type AgentSettingsPanelProps = { cronCreateBusy?: boolean; onCreateCronJob?: (draft: CronCreateDraft) => Promise | void; controlUiUrl?: string | null; + adapterType?: StudioGatewayAdapterType | null; skillsReport?: SkillStatusReport | null; skillsLoading?: boolean; skillsError?: string | null; @@ -248,6 +250,7 @@ export const AgentSettingsPanel = ({ cronCreateBusy = false, onCreateCronJob = () => {}, controlUiUrl = null, + adapterType = "openclaw", skillsReport = null, skillsLoading = false, skillsError = null, @@ -267,6 +270,7 @@ export const AgentSettingsPanel = ({ onSkillApiKeyChange = () => {}, onSaveSkillApiKey = () => {}, }: AgentSettingsPanelProps) => { + const isOpenClawRuntime = adapterType === "openclaw"; const initialPermissionsDraft = permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent)); const [permissionsBaselineValue, setPermissionsBaselineValue] = @@ -785,16 +789,18 @@ export const AgentSettingsPanel = ({ })} ) : null} -
-

Heartbeats

-
- Heartbeat automation controls are coming soon. -
-
+ {isOpenClawRuntime ? ( +
+

Heartbeats

+
+ Heartbeat automation controls are coming soon. +
+
+ ) : null} ) : null} - {mode === "advanced" ? ( + {mode === "advanced" && isOpenClawRuntime ? ( <>

Danger Zone

diff --git a/src/features/agents/screens/AgentsPageScreen.tsx b/src/features/agents/screens/AgentsPageScreen.tsx index 012c21b..394aa32 100644 --- a/src/features/agents/screens/AgentsPageScreen.tsx +++ b/src/features/agents/screens/AgentsPageScreen.tsx @@ -1691,7 +1691,8 @@ const AgentsPageScreen = () => { onDeleteCronJob={(jobId) => settingsMutationController.handleDeleteCronJob(inspectSidebarAgent.agentId, jobId) } - controlUiUrl={controlUiUrl} + controlUiUrl={selectedAdapterType === "openclaw" ? controlUiUrl : null} + adapterType={selectedAdapterType} /> diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index a408949..b585fdb 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -727,7 +727,8 @@ export const useGatewayConnection = ( } : null; // When the user has no saved gateway URL, prefer the runtime - // localGatewayDefaults (from openclaw.json / CLAW3D_GATEWAY_URL) + // localGatewayDefaults (from openclaw.json, CLAW3D_GATEWAY_URL, + // or detected local Hermes/demo adapter ports) // over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale // or empty if the operator forgot to rebuild after .env changes. const hasSavedUrl = Boolean(gateway?.url?.trim()); diff --git a/src/lib/studio/settings-store.ts b/src/lib/studio/settings-store.ts index d8ef36f..10ff884 100644 --- a/src/lib/studio/settings-store.ts +++ b/src/lib/studio/settings-store.ts @@ -6,6 +6,9 @@ import { defaultStudioSettings, mergeStudioSettings, normalizeStudioSettings, + type StudioGatewayAdapterType, + type StudioGatewayProfile, + type StudioGatewaySettings, type StudioSettings, type StudioSettingsPatch, } from "@/lib/studio/settings"; @@ -16,6 +19,7 @@ import { const SETTINGS_DIRNAME = "claw3d"; const SETTINGS_FILENAME = "settings.json"; const OPENCLAW_CONFIG_FILENAME = "openclaw.json"; +const DEFAULT_LOCAL_GATEWAY_PORT = 18789; export const resolveStudioSettingsPath = () => path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME); @@ -23,11 +27,21 @@ export const resolveStudioSettingsPath = () => const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object"); -const readOpenclawGatewayDefaults = (): { +const buildGatewaySettings = (params: { + adapterType: StudioGatewayAdapterType; url: string; - token: string; - adapterType: "openclaw"; -} | null => { + token?: string; + profiles?: Partial>; +}): StudioGatewaySettings => ({ + url: params.url, + token: params.token ?? "", + adapterType: params.adapterType, + ...(params.profiles ? { profiles: params.profiles } : {}), +}); + +const buildLocalProfile = (url: string, token = ""): StudioGatewayProfile => ({ url, token }); + +const readOpenclawGatewayDefaults = (): StudioGatewaySettings | null => { try { const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME); if (!fs.existsSync(configPath)) return null; @@ -40,29 +54,110 @@ const readOpenclawGatewayDefaults = (): { const token = typeof auth?.token === "string" ? auth.token.trim() : ""; const port = typeof gateway.port === "number" && Number.isFinite(gateway.port) ? gateway.port : null; if (!token) return null; - const url = port ? `ws://localhost:${port}` : ""; + const url = port ? `ws://localhost:${port}` : `ws://localhost:${DEFAULT_LOCAL_GATEWAY_PORT}`; if (!url) return null; - return { url, token, adapterType: "openclaw" }; + return buildGatewaySettings({ + adapterType: "openclaw", + url, + token, + profiles: { + openclaw: buildLocalProfile(url, token), + }, + }); } catch { return null; } }; -export const loadLocalGatewayDefaults = (): { - url: string; - token: string; - adapterType: "openclaw"; -} | null => { - const fromFile = readOpenclawGatewayDefaults(); - if (fromFile) return fromFile; - // Fall back to env vars so operators can configure the gateway URL at - // runtime without openclaw.json and without a rebuild. - const envUrl = process.env.CLAW3D_GATEWAY_URL?.trim(); - const envToken = process.env.CLAW3D_GATEWAY_TOKEN?.trim(); - if (envUrl) return { url: envUrl, token: envToken ?? "", adapterType: "openclaw" }; +const normalizeAdapterType = (value: string | undefined): StudioGatewayAdapterType | null => { + const normalized = value?.trim().toLowerCase(); + if (normalized === "openclaw" || normalized === "hermes" || normalized === "demo" || normalized === "custom") { + return normalized; + } return null; }; +const readPortBasedGatewayProfile = ( + adapterType: Extract, + envKey: "HERMES_ADAPTER_PORT" | "DEMO_ADAPTER_PORT" +): StudioGatewayProfile | null => { + const rawPort = process.env[envKey]?.trim(); + if (!rawPort) return null; + const port = Number.parseInt(rawPort, 10); + if (!Number.isFinite(port) || port <= 0) return null; + return buildLocalProfile(`ws://localhost:${port}`); +}; + +const buildEnvGatewayDefaults = (): StudioGatewaySettings | null => { + const envUrl = process.env.CLAW3D_GATEWAY_URL?.trim(); + const envToken = process.env.CLAW3D_GATEWAY_TOKEN?.trim() ?? ""; + const envAdapterType = + normalizeAdapterType(process.env.CLAW3D_GATEWAY_ADAPTER_TYPE) ?? "openclaw"; + + const hermesProfile = readPortBasedGatewayProfile("hermes", "HERMES_ADAPTER_PORT"); + const demoProfile = readPortBasedGatewayProfile("demo", "DEMO_ADAPTER_PORT"); + + const profiles: Partial> = {}; + if (hermesProfile) profiles.hermes = hermesProfile; + if (demoProfile) profiles.demo = demoProfile; + + if (envUrl) { + profiles[envAdapterType] = buildLocalProfile(envUrl, envToken); + return buildGatewaySettings({ + adapterType: envAdapterType, + url: envUrl, + token: envToken, + profiles, + }); + } + + const fallbackProfile = profiles.hermes ?? profiles.demo ?? null; + if (!fallbackProfile) return null; + const fallbackAdapterType = profiles.hermes ? "hermes" : "demo"; + return buildGatewaySettings({ + adapterType: fallbackAdapterType, + url: fallbackProfile.url, + token: fallbackProfile.token, + profiles, + }); +}; + +const mergeGatewayProfiles = ( + base: StudioGatewaySettings, + extra: StudioGatewaySettings | null +): StudioGatewaySettings => { + if (!extra?.profiles) { + return base; + } + const mergedProfiles: Partial> = { + ...(base.profiles ?? {}), + }; + for (const [adapterType, profile] of Object.entries(extra.profiles) as Array< + [StudioGatewayAdapterType, StudioGatewayProfile | undefined] + >) { + if (!profile || mergedProfiles[adapterType]) { + continue; + } + mergedProfiles[adapterType] = profile; + } + return { + ...base, + profiles: mergedProfiles, + }; +}; + +export const loadLocalGatewayDefaults = (): StudioGatewaySettings | null => { + const fromFile = readOpenclawGatewayDefaults(); + const fromEnv = buildEnvGatewayDefaults(); + if (fromFile) { + return mergeGatewayProfiles(fromFile, fromEnv); + } + // Fall back to env vars so operators can configure the gateway URL at + // runtime without openclaw.json and without a rebuild. If no explicit + // URL is provided, also expose local Hermes/Demo adapter ports when set. + return fromEnv; +}; + export const loadStudioSettings = (): StudioSettings => { const settingsPath = resolveStudioSettingsPath(); if (!fs.existsSync(settingsPath)) { diff --git a/tests/unit/agentSettingsPanel.test.ts b/tests/unit/agentSettingsPanel.test.ts index 51a0fa6..75540d4 100644 --- a/tests/unit/agentSettingsPanel.test.ts +++ b/tests/unit/agentSettingsPanel.test.ts @@ -1110,6 +1110,7 @@ describe("AgentSettingsPanel", () => { cronDeleteBusyJobId: null, onRunCronJob: vi.fn(), onDeleteCronJob: vi.fn(), + adapterType: "openclaw", }) ); @@ -1117,6 +1118,29 @@ describe("AgentSettingsPanel", () => { expect(screen.getByText("Heartbeat automation controls are coming soon.")).toBeInTheDocument(); }); + it("hides_heartbeat_coming_soon_for_hermes", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [createCronJob("job-1")], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + adapterType: "hermes", + }) + ); + + expect(screen.queryByTestId("agent-settings-heartbeat-coming-soon")).not.toBeInTheDocument(); + }); + it("shows_control_ui_section_in_advanced_mode", () => { render( createElement(AgentSettingsPanel, { @@ -1133,6 +1157,7 @@ describe("AgentSettingsPanel", () => { cronDeleteBusyJobId: null, onRunCronJob: vi.fn(), onDeleteCronJob: vi.fn(), + adapterType: "openclaw", }) ); @@ -1140,6 +1165,30 @@ describe("AgentSettingsPanel", () => { expect(screen.getByRole("button", { name: "Open Full Control UI" })).toBeDisabled(); }); + it("hides_control_ui_section_for_hermes", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "advanced", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + adapterType: "hermes", + }) + ); + + expect(screen.queryByTestId("agent-settings-control-ui")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open Full Control UI" })).not.toBeInTheDocument(); + }); + it("renders_enabled_control_ui_link_when_available", () => { render( createElement(AgentSettingsPanel, { diff --git a/tests/unit/gatewayEnvDefaults.test.ts b/tests/unit/gatewayEnvDefaults.test.ts index b65e2e2..2e86c94 100644 --- a/tests/unit/gatewayEnvDefaults.test.ts +++ b/tests/unit/gatewayEnvDefaults.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; describe("loadLocalGatewayDefaults with CLAW3D_GATEWAY_URL", () => { const originalEnv = { ...process.env }; @@ -17,7 +20,14 @@ describe("loadLocalGatewayDefaults with CLAW3D_GATEWAY_URL", () => { "../../src/lib/studio/settings-store" ); const result = loadLocalGatewayDefaults(); - expect(result).toEqual({ url: "ws://my-gateway:18789", token: "my-token" }); + expect(result).toEqual({ + url: "ws://my-gateway:18789", + token: "my-token", + adapterType: "openclaw", + profiles: { + openclaw: { url: "ws://my-gateway:18789", token: "my-token" }, + }, + }); }); it("returns env-based defaults with empty token when only URL is set", async () => { @@ -28,7 +38,14 @@ describe("loadLocalGatewayDefaults with CLAW3D_GATEWAY_URL", () => { "../../src/lib/studio/settings-store" ); const result = loadLocalGatewayDefaults(); - expect(result).toEqual({ url: "ws://my-gateway:18789", token: "" }); + expect(result).toEqual({ + url: "ws://my-gateway:18789", + token: "", + adapterType: "openclaw", + profiles: { + openclaw: { url: "ws://my-gateway:18789", token: "" }, + }, + }); }); it("returns null when no env var and no openclaw.json", async () => { @@ -57,4 +74,110 @@ describe("loadLocalGatewayDefaults with CLAW3D_GATEWAY_URL", () => { } // If no file exists in CI, it falls back to env — that's also correct }); + + it("uses CLAW3D_GATEWAY_ADAPTER_TYPE for Hermes env defaults", async () => { + process.env.CLAW3D_GATEWAY_URL = "ws://my-hermes:18789"; + process.env.CLAW3D_GATEWAY_ADAPTER_TYPE = "hermes"; + delete process.env.CLAW3D_GATEWAY_TOKEN; + process.env.OPENCLAW_STATE_DIR = "/tmp/claw3d-test-nonexistent-" + Date.now(); + const { loadLocalGatewayDefaults } = await import( + "../../src/lib/studio/settings-store" + ); + const result = loadLocalGatewayDefaults(); + expect(result).toEqual({ + url: "ws://my-hermes:18789", + token: "", + adapterType: "hermes", + profiles: { + hermes: { url: "ws://my-hermes:18789", token: "" }, + }, + }); + }); + + it("exposes local Hermes adapter defaults when only HERMES_ADAPTER_PORT is set", async () => { + delete process.env.CLAW3D_GATEWAY_URL; + delete process.env.CLAW3D_GATEWAY_TOKEN; + process.env.HERMES_ADAPTER_PORT = "19444"; + process.env.OPENCLAW_STATE_DIR = "/tmp/claw3d-test-nonexistent-" + Date.now(); + const { loadLocalGatewayDefaults } = await import( + "../../src/lib/studio/settings-store" + ); + const result = loadLocalGatewayDefaults(); + expect(result).toEqual({ + url: "ws://localhost:19444", + token: "", + adapterType: "hermes", + profiles: { + hermes: { url: "ws://localhost:19444", token: "" }, + }, + }); + }); + + it("merges Hermes adapter defaults into file-backed OpenClaw defaults", async () => { + delete process.env.CLAW3D_GATEWAY_URL; + delete process.env.CLAW3D_GATEWAY_TOKEN; + delete process.env.CLAW3D_GATEWAY_ADAPTER_TYPE; + process.env.HERMES_ADAPTER_PORT = "19444"; + + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "claw3d-gateway-defaults-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + fs.writeFileSync( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + gateway: { + port: 18789, + auth: { token: "file-token" }, + }, + }), + "utf8" + ); + + const { loadLocalGatewayDefaults } = await import( + "../../src/lib/studio/settings-store" + ); + const result = loadLocalGatewayDefaults(); + + expect(result).toEqual({ + url: "ws://localhost:18789", + token: "file-token", + adapterType: "openclaw", + profiles: { + openclaw: { url: "ws://localhost:18789", token: "file-token" }, + hermes: { url: "ws://localhost:19444", token: "" }, + }, + }); + }); + + it("keeps file-backed openclaw profile when CLAW3D_GATEWAY_URL is also set", async () => { + process.env.CLAW3D_GATEWAY_URL = "ws://env-gateway:19999"; + process.env.CLAW3D_GATEWAY_TOKEN = "env-token"; + delete process.env.CLAW3D_GATEWAY_ADAPTER_TYPE; + + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "claw3d-gateway-defaults-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + fs.writeFileSync( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + gateway: { + port: 18789, + auth: { token: "file-token" }, + }, + }), + "utf8" + ); + + const { loadLocalGatewayDefaults } = await import( + "../../src/lib/studio/settings-store" + ); + const result = loadLocalGatewayDefaults(); + + expect(result).toEqual({ + url: "ws://localhost:18789", + token: "file-token", + adapterType: "openclaw", + profiles: { + openclaw: { url: "ws://localhost:18789", token: "file-token" }, + }, + }); + }); }); diff --git a/tests/unit/studioSettingsRoute.test.ts b/tests/unit/studioSettingsRoute.test.ts index 92904b0..3af4730 100644 --- a/tests/unit/studioSettingsRoute.test.ts +++ b/tests/unit/studioSettingsRoute.test.ts @@ -47,8 +47,20 @@ describe("studio settings route", () => { const response = await GET(); const body = (await response.json()) as { - settings?: { gateway?: { url?: string; tokenConfigured?: boolean } | null }; - localGatewayDefaults?: { url?: string; tokenConfigured?: boolean } | null; + settings?: { + gateway?: { + url?: string; + tokenConfigured?: boolean; + adapterType?: string; + profiles?: Record; + } | null; + }; + localGatewayDefaults?: { + url?: string; + tokenConfigured?: boolean; + adapterType?: string; + profiles?: Record; + } | null; }; expect(response.status).toBe(200); @@ -56,11 +68,23 @@ describe("studio settings route", () => { url: "ws://localhost:18791", tokenConfigured: true, adapterType: "openclaw", + profiles: { + openclaw: { + url: "ws://localhost:18791", + tokenConfigured: true, + }, + }, }); expect(body.settings?.gateway).toEqual({ url: "ws://localhost:18791", tokenConfigured: true, adapterType: "openclaw", + profiles: { + openclaw: { + url: "ws://localhost:18791", + tokenConfigured: true, + }, + }, }); });