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:
+11
-2
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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> | 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 = ({
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{isOpenClawRuntime ? (
|
||||
<section className="sidebar-section" data-testid="agent-settings-heartbeat-coming-soon">
|
||||
<h3 className="sidebar-section-title">Heartbeats</h3>
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">
|
||||
Heartbeat automation controls are coming soon.
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{mode === "advanced" ? (
|
||||
{mode === "advanced" && isOpenClawRuntime ? (
|
||||
<>
|
||||
<section className="sidebar-section mt-8" data-testid="agent-settings-control-ui">
|
||||
<h3 className="sidebar-section-title ui-text-danger">Danger Zone</h3>
|
||||
|
||||
@@ -1691,7 +1691,8 @@ const AgentsPageScreen = () => {
|
||||
onDeleteCronJob={(jobId) =>
|
||||
settingsMutationController.handleDeleteCronJob(inspectSidebarAgent.agentId, jobId)
|
||||
}
|
||||
controlUiUrl={controlUiUrl}
|
||||
controlUiUrl={selectedAdapterType === "openclaw" ? controlUiUrl : null}
|
||||
adapterType={selectedAdapterType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
const readOpenclawGatewayDefaults = (): {
|
||||
const buildGatewaySettings = (params: {
|
||||
adapterType: StudioGatewayAdapterType;
|
||||
url: string;
|
||||
token: string;
|
||||
adapterType: "openclaw";
|
||||
} | null => {
|
||||
token?: string;
|
||||
profiles?: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>>;
|
||||
}): 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<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 => {
|
||||
const settingsPath = resolveStudioSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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);
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user