Files
claw3d/src/lib/studio/settings-store.ts
T
gsknnft 4be98d7080 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
2026-04-03 17:35:13 -05:00

204 lines
6.9 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import {
defaultStudioSettings,
mergeStudioSettings,
normalizeStudioSettings,
type StudioGatewayAdapterType,
type StudioGatewayProfile,
type StudioGatewaySettings,
type StudioSettings,
type StudioSettingsPatch,
} from "@/lib/studio/settings";
// Studio settings are intentionally stored as a local JSON file for a single-user workflow.
// That includes gateway connection details, so treat the state directory as plaintext secret
// storage and document any changes to this threat model in README.md and SECURITY.md.
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);
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const buildGatewaySettings = (params: {
adapterType: StudioGatewayAdapterType;
url: string;
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;
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) return null;
const gateway = isRecord(parsed.gateway) ? parsed.gateway : null;
if (!gateway) return null;
const auth = isRecord(gateway.auth) ? gateway.auth : null;
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}` : `ws://localhost:${DEFAULT_LOCAL_GATEWAY_PORT}`;
if (!url) return null;
return buildGatewaySettings({
adapterType: "openclaw",
url,
token,
profiles: {
openclaw: buildLocalProfile(url, token),
},
});
} catch {
return null;
}
};
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)) {
const defaults = defaultStudioSettings();
const gateway = loadLocalGatewayDefaults();
return gateway ? { ...defaults, gateway } : defaults;
}
const raw = fs.readFileSync(settingsPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const settings = normalizeStudioSettings(parsed);
if (!settings.gateway?.token) {
const gateway = loadLocalGatewayDefaults();
if (gateway) {
return {
...settings,
gateway: settings.gateway?.url?.trim()
? {
url: settings.gateway.url.trim(),
token: gateway.token,
adapterType: settings.gateway.adapterType,
}
: gateway,
};
}
}
return settings;
};
export const saveStudioSettings = (next: StudioSettings) => {
const settingsPath = resolveStudioSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2), "utf8");
};
export const applyStudioSettingsPatch = (patch: StudioSettingsPatch): StudioSettings => {
const current = loadStudioSettings();
const next = mergeStudioSettings(current, patch);
saveStudioSettings(next);
return next;
};