fix: clean up Hermes-visible OpenClaw leftovers (#97)

* cleanup openclaw session leftovers - hermes can breathe now

* fix: load hermes adapter env from .env

* fix: redact secrets from hermes adapter error output

* addressed review findings

* address luke findings #2
This commit is contained in:
gsknnft
2026-04-03 18:35:13 -04:00
committed by GitHub
parent 051d0ce469
commit 4be98d7080
10 changed files with 404 additions and 45 deletions
+11 -2
View File
@@ -3,10 +3,17 @@ NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
# Runtime gateway URL — takes effect on restart without a rebuild. # Runtime gateway URL — takes effect on restart without a rebuild.
# Use this instead of NEXT_PUBLIC_GATEWAY_URL when you want to change the # 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 # gateway endpoint without re-running `npm run build`.
# fallback when openclaw.json is not present.
# CLAW3D_GATEWAY_URL=ws://localhost:18789 # CLAW3D_GATEWAY_URL=ws://localhost:18789
# CLAW3D_GATEWAY_TOKEN= # 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 UI
DEBUG=true DEBUG=true
@@ -30,10 +37,12 @@ DEBUG=true
# HERMES_ADAPTER_PORT=18789 # HERMES_ADAPTER_PORT=18789
# HERMES_MODEL=hermes # HERMES_MODEL=hermes
# HERMES_AGENT_NAME=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) # Demo gateway (no OpenClaw or Hermes required)
# Run `npm run demo-gateway` and connect Claw3D to ws://localhost:18789 # Run `npm run demo-gateway` and connect Claw3D to ws://localhost:18789
# DEMO_ADAPTER_PORT=18789 # DEMO_ADAPTER_PORT=18789
# If CLAW3D_GATEWAY_URL is unset, Studio can still detect this local adapter port.
# Optional: voice features # Optional: voice features
# ELEVENLABS_API_KEY= # ELEVENLABS_API_KEY=
+4 -1
View File
@@ -230,7 +230,10 @@ Common environment variables:
- `UPSTREAM_ALLOWLIST` restricts which upstream gateway hosts Studio may proxy to. Set this in production. - `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`. - `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. - `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_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. - `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. - `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`, and `ELEVENLABS_MODEL_ID` enable voice reply integration.
+59 -11
View File
@@ -27,6 +27,35 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const { WebSocketServer } = require("ws"); 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_URL = (process.env.HERMES_API_URL || "http://localhost:8642").replace(/\/$/, "");
const HERMES_API_KEY = process.env.HERMES_API_KEY || ""; const HERMES_API_KEY = process.env.HERMES_API_KEY || "";
const ADAPTER_PORT = parseInt(process.env.HERMES_ADAPTER_PORT || "18789", 10); const ADAPTER_PORT = parseInt(process.env.HERMES_ADAPTER_PORT || "18789", 10);
@@ -227,7 +256,7 @@ function loadHistoryFromDisk() {
} }
} }
} catch (err) { } 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.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
fs.writeFileSync(HISTORY_FILE, JSON.stringify(data, null, 2), "utf8"); fs.writeFileSync(HISTORY_FILE, JSON.stringify(data, null, 2), "utf8");
} catch (err) { } catch (err) {
console.warn("[hermes-adapter] Could not save history:", err.message); console.warn("[hermes-adapter] Could not save history:", sanitizeErrorMessage(err));
} }
}, 500); }, 500);
} }
@@ -261,6 +290,17 @@ function randomId() {
return require("crypto").randomBytes(8).toString("hex"); 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 // Hermes HTTP API helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -311,6 +351,12 @@ async function readJsonBody(res) {
return JSON.parse(raw); 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) { function extractOpenAiStyleError(payload, fallbackMessage) {
if (payload && typeof payload === "object") { if (payload && typeof payload === "object") {
const message = const message =
@@ -607,8 +653,9 @@ async function execDelegateTask(args) {
byAgent: [{ agentId: targetId, recent: [{ key: sessionKey, updatedAt: Date.now() }] }] } }, byAgent: [{ agentId: targetId, recent: [{ key: sessionKey, updatedAt: Date.now() }] }] } },
}); });
} catch (err) { } catch (err) {
emitSub("error", { errorMessage: err.message }); const message = sanitizeErrorMessage(err);
return JSON.stringify({ ok: false, error: err.message }); emitSub("error", { errorMessage: message });
return JSON.stringify({ ok: false, error: message });
} }
return JSON.stringify({ ok: true, agent_id: targetId, response: responseText }); 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() }] }] } } }); byAgent: [{ agentId: sessionAgentId, recent: [{ key: sessionKey, updatedAt: Date.now() }] }] } } });
} }
} catch (err) { } 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", {}); else emitChat("aborted", {});
} finally { } finally {
activeRuns.delete(runId); activeRuns.delete(runId);
@@ -1134,7 +1181,7 @@ function startAdapter() {
const wss = new WebSocketServer({ server: httpServer }); const wss = new WebSocketServer({ server: httpServer });
wss.on("error", (err) => { 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) => { wss.on("connection", (ws) => {
@@ -1144,7 +1191,7 @@ function startAdapter() {
const send = (frame) => { const send = (frame) => {
if (ws.readyState === ws.OPEN) { if (ws.readyState === ws.OPEN) {
try { ws.send(JSON.stringify(frame)); } 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); const response = await handleMethod(method, params, id, sendEventFn);
send(response); send(response);
} catch (err) { } catch (err) {
console.error(`[hermes-adapter] Error handling ${method}:`, err.message); const message = sanitizeErrorMessage(err);
send(resErr(id, "internal_error", err.message || "Internal error")); console.error(`[hermes-adapter] Error handling ${method}:`, message);
send(resErr(id, "internal_error", message || "Internal error"));
} }
}); });
ws.on("close", () => activeSendEventFns.delete(sendEventFn)); ws.on("close", () => activeSendEventFns.delete(sendEventFn));
ws.on("error", (err) => { ws.on("error", (err) => {
console.error("[hermes-adapter] WebSocket error:", err.message); console.error("[hermes-adapter] WebSocket error:", sanitizeErrorMessage(err));
activeSendEventFns.delete(sendEventFn); activeSendEventFns.delete(sendEventFn);
}); });
}); });
@@ -1221,7 +1269,7 @@ function startAdapter() {
if (err.code === "EADDRINUSE") { if (err.code === "EADDRINUSE") {
console.error(`[hermes-adapter] Port ${ADAPTER_PORT} in use. Set HERMES_ADAPTER_PORT to change it.`); console.error(`[hermes-adapter] Port ${ADAPTER_PORT} in use. Set HERMES_ADAPTER_PORT to change it.`);
} else { } else {
console.error("[hermes-adapter] Server error:", err.message); console.error("[hermes-adapter] Server error:", sanitizeErrorMessage(err));
} }
process.exit(1); process.exit(1);
}); });
@@ -25,6 +25,7 @@ import type { AgentState } from "@/features/agents/state/store";
import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPayloadBuilder"; import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPayloadBuilder";
import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types"; import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types";
import type { SkillStatusReport } from "@/lib/skills/types"; import type { SkillStatusReport } from "@/lib/skills/types";
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
export type AgentSettingsPanelProps = { export type AgentSettingsPanelProps = {
agent: AgentState; agent: AgentState;
@@ -47,6 +48,7 @@ export type AgentSettingsPanelProps = {
cronCreateBusy?: boolean; cronCreateBusy?: boolean;
onCreateCronJob?: (draft: CronCreateDraft) => Promise<void> | void; onCreateCronJob?: (draft: CronCreateDraft) => Promise<void> | void;
controlUiUrl?: string | null; controlUiUrl?: string | null;
adapterType?: StudioGatewayAdapterType | null;
skillsReport?: SkillStatusReport | null; skillsReport?: SkillStatusReport | null;
skillsLoading?: boolean; skillsLoading?: boolean;
skillsError?: string | null; skillsError?: string | null;
@@ -248,6 +250,7 @@ export const AgentSettingsPanel = ({
cronCreateBusy = false, cronCreateBusy = false,
onCreateCronJob = () => {}, onCreateCronJob = () => {},
controlUiUrl = null, controlUiUrl = null,
adapterType = "openclaw",
skillsReport = null, skillsReport = null,
skillsLoading = false, skillsLoading = false,
skillsError = null, skillsError = null,
@@ -267,6 +270,7 @@ export const AgentSettingsPanel = ({
onSkillApiKeyChange = () => {}, onSkillApiKeyChange = () => {},
onSaveSkillApiKey = () => {}, onSaveSkillApiKey = () => {},
}: AgentSettingsPanelProps) => { }: AgentSettingsPanelProps) => {
const isOpenClawRuntime = adapterType === "openclaw";
const initialPermissionsDraft = const initialPermissionsDraft =
permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent)); permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent));
const [permissionsBaselineValue, setPermissionsBaselineValue] = const [permissionsBaselineValue, setPermissionsBaselineValue] =
@@ -785,16 +789,18 @@ export const AgentSettingsPanel = ({
})} })}
</div> </div>
) : null} ) : null}
{isOpenClawRuntime ? (
<section className="sidebar-section" data-testid="agent-settings-heartbeat-coming-soon"> <section className="sidebar-section" data-testid="agent-settings-heartbeat-coming-soon">
<h3 className="sidebar-section-title">Heartbeats</h3> <h3 className="sidebar-section-title">Heartbeats</h3>
<div className="mt-3 text-[11px] text-muted-foreground"> <div className="mt-3 text-[11px] text-muted-foreground">
Heartbeat automation controls are coming soon. Heartbeat automation controls are coming soon.
</div> </div>
</section> </section>
) : null}
</section> </section>
) : null} ) : null}
{mode === "advanced" ? ( {mode === "advanced" && isOpenClawRuntime ? (
<> <>
<section className="sidebar-section mt-8" data-testid="agent-settings-control-ui"> <section className="sidebar-section mt-8" data-testid="agent-settings-control-ui">
<h3 className="sidebar-section-title ui-text-danger">Danger Zone</h3> <h3 className="sidebar-section-title ui-text-danger">Danger Zone</h3>
@@ -1691,7 +1691,8 @@ const AgentsPageScreen = () => {
onDeleteCronJob={(jobId) => onDeleteCronJob={(jobId) =>
settingsMutationController.handleDeleteCronJob(inspectSidebarAgent.agentId, jobId) settingsMutationController.handleDeleteCronJob(inspectSidebarAgent.agentId, jobId)
} }
controlUiUrl={controlUiUrl} controlUiUrl={selectedAdapterType === "openclaw" ? controlUiUrl : null}
adapterType={selectedAdapterType}
/> />
</div> </div>
</div> </div>
+2 -1
View File
@@ -727,7 +727,8 @@ export const useGatewayConnection = (
} }
: null; : null;
// When the user has no saved gateway URL, prefer the runtime // 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 // over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale
// or empty if the operator forgot to rebuild after .env changes. // or empty if the operator forgot to rebuild after .env changes.
const hasSavedUrl = Boolean(gateway?.url?.trim()); const hasSavedUrl = Boolean(gateway?.url?.trim());
+113 -18
View File
@@ -6,6 +6,9 @@ import {
defaultStudioSettings, defaultStudioSettings,
mergeStudioSettings, mergeStudioSettings,
normalizeStudioSettings, normalizeStudioSettings,
type StudioGatewayAdapterType,
type StudioGatewayProfile,
type StudioGatewaySettings,
type StudioSettings, type StudioSettings,
type StudioSettingsPatch, type StudioSettingsPatch,
} from "@/lib/studio/settings"; } from "@/lib/studio/settings";
@@ -16,6 +19,7 @@ import {
const SETTINGS_DIRNAME = "claw3d"; const SETTINGS_DIRNAME = "claw3d";
const SETTINGS_FILENAME = "settings.json"; const SETTINGS_FILENAME = "settings.json";
const OPENCLAW_CONFIG_FILENAME = "openclaw.json"; const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
const DEFAULT_LOCAL_GATEWAY_PORT = 18789;
export const resolveStudioSettingsPath = () => export const resolveStudioSettingsPath = () =>
path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME); path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME);
@@ -23,11 +27,21 @@ export const resolveStudioSettingsPath = () =>
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object"); Boolean(value && typeof value === "object");
const readOpenclawGatewayDefaults = (): { const buildGatewaySettings = (params: {
adapterType: StudioGatewayAdapterType;
url: string; url: string;
token: string; token?: string;
adapterType: "openclaw"; profiles?: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>>;
} | null => { }): 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 { try {
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME); const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
if (!fs.existsSync(configPath)) return null; if (!fs.existsSync(configPath)) return null;
@@ -40,29 +54,110 @@ const readOpenclawGatewayDefaults = (): {
const token = typeof auth?.token === "string" ? auth.token.trim() : ""; const token = typeof auth?.token === "string" ? auth.token.trim() : "";
const port = typeof gateway.port === "number" && Number.isFinite(gateway.port) ? gateway.port : null; const port = typeof gateway.port === "number" && Number.isFinite(gateway.port) ? gateway.port : null;
if (!token) return 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; if (!url) return null;
return { url, token, adapterType: "openclaw" }; return buildGatewaySettings({
adapterType: "openclaw",
url,
token,
profiles: {
openclaw: buildLocalProfile(url, token),
},
});
} catch { } catch {
return null; return null;
} }
}; };
export const loadLocalGatewayDefaults = (): { const normalizeAdapterType = (value: string | undefined): StudioGatewayAdapterType | null => {
url: string; const normalized = value?.trim().toLowerCase();
token: string; if (normalized === "openclaw" || normalized === "hermes" || normalized === "demo" || normalized === "custom") {
adapterType: "openclaw"; return normalized;
} | 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" };
return null; return null;
}; };
const readPortBasedGatewayProfile = (
adapterType: Extract<StudioGatewayAdapterType, "hermes" | "demo">,
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<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {};
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<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {
...(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 => { export const loadStudioSettings = (): StudioSettings => {
const settingsPath = resolveStudioSettingsPath(); const settingsPath = resolveStudioSettingsPath();
if (!fs.existsSync(settingsPath)) { if (!fs.existsSync(settingsPath)) {
+49
View File
@@ -1110,6 +1110,7 @@ describe("AgentSettingsPanel", () => {
cronDeleteBusyJobId: null, cronDeleteBusyJobId: null,
onRunCronJob: vi.fn(), onRunCronJob: vi.fn(),
onDeleteCronJob: vi.fn(), onDeleteCronJob: vi.fn(),
adapterType: "openclaw",
}) })
); );
@@ -1117,6 +1118,29 @@ describe("AgentSettingsPanel", () => {
expect(screen.getByText("Heartbeat automation controls are coming soon.")).toBeInTheDocument(); 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", () => { it("shows_control_ui_section_in_advanced_mode", () => {
render( render(
createElement(AgentSettingsPanel, { createElement(AgentSettingsPanel, {
@@ -1133,6 +1157,7 @@ describe("AgentSettingsPanel", () => {
cronDeleteBusyJobId: null, cronDeleteBusyJobId: null,
onRunCronJob: vi.fn(), onRunCronJob: vi.fn(),
onDeleteCronJob: vi.fn(), onDeleteCronJob: vi.fn(),
adapterType: "openclaw",
}) })
); );
@@ -1140,6 +1165,30 @@ describe("AgentSettingsPanel", () => {
expect(screen.getByRole("button", { name: "Open Full Control UI" })).toBeDisabled(); 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", () => { it("renders_enabled_control_ui_link_when_available", () => {
render( render(
createElement(AgentSettingsPanel, { createElement(AgentSettingsPanel, {
+125 -2
View File
@@ -1,4 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; 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", () => { describe("loadLocalGatewayDefaults with CLAW3D_GATEWAY_URL", () => {
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
@@ -17,7 +20,14 @@ describe("loadLocalGatewayDefaults with CLAW3D_GATEWAY_URL", () => {
"../../src/lib/studio/settings-store" "../../src/lib/studio/settings-store"
); );
const result = loadLocalGatewayDefaults(); 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 () => { 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" "../../src/lib/studio/settings-store"
); );
const result = loadLocalGatewayDefaults(); 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 () => { 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 // 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" },
},
});
});
}); });
+26 -2
View File
@@ -47,8 +47,20 @@ describe("studio settings route", () => {
const response = await GET(); const response = await GET();
const body = (await response.json()) as { const body = (await response.json()) as {
settings?: { gateway?: { url?: string; tokenConfigured?: boolean } | null }; settings?: {
localGatewayDefaults?: { url?: string; tokenConfigured?: boolean } | null; gateway?: {
url?: string;
tokenConfigured?: boolean;
adapterType?: string;
profiles?: Record<string, { url?: string; tokenConfigured?: boolean }>;
} | null;
};
localGatewayDefaults?: {
url?: string;
tokenConfigured?: boolean;
adapterType?: string;
profiles?: Record<string, { url?: string; tokenConfigured?: boolean }>;
} | null;
}; };
expect(response.status).toBe(200); expect(response.status).toBe(200);
@@ -56,11 +68,23 @@ describe("studio settings route", () => {
url: "ws://localhost:18791", url: "ws://localhost:18791",
tokenConfigured: true, tokenConfigured: true,
adapterType: "openclaw", adapterType: "openclaw",
profiles: {
openclaw: {
url: "ws://localhost:18791",
tokenConfigured: true,
},
},
}); });
expect(body.settings?.gateway).toEqual({ expect(body.settings?.gateway).toEqual({
url: "ws://localhost:18791", url: "ws://localhost:18791",
tokenConfigured: true, tokenConfigured: true,
adapterType: "openclaw", adapterType: "openclaw",
profiles: {
openclaw: {
url: "ws://localhost:18791",
tokenConfigured: true,
},
},
}); });
}); });