claw-web-v2 / server /runtime /config.ts
Claw Web
Fix all 10 critical audit bugs:
c663926
/**
* Configuration system for claw-web.
* EXACT parity with original claw-code Rust config.rs:
* - File-based persistence (.claw.json, .claw/settings.json, .claw/settings.local.json)
* - Config discovery from user home + project cwd
* - Deep merge with source priority (User < Project < Local)
* - MCP server configs (stdio, sse, http, ws)
* - Hooks config (PreToolUse, PostToolUse)
* - Plugin config (enabled, directories, install root)
* - Permission mode config
* - Model config
*/
import * as fs from "fs";
import * as path from "path";
// ─── Config Source (matches original ConfigSource enum) ─────────────────
export enum ConfigSource {
User = "user",
Project = "project",
Local = "local",
}
// ─── Permission Modes (matches original ResolvedPermissionMode) ─────────
export enum ResolvedPermissionMode {
ReadOnly = "read_only",
WorkspaceWrite = "workspace_write",
DangerFullAccess = "full_access",
}
// ─── Config Entry (matches original ConfigEntry) ────────────────────────
export interface ConfigEntry {
source: ConfigSource;
path: string;
}
// ─── MCP Transport Types (matches original McpTransport) ────────────────
export enum McpTransport {
Stdio = "stdio",
Sse = "sse",
Http = "http",
Ws = "ws",
Sdk = "sdk",
ManagedProxy = "managed_proxy",
}
// ─── MCP Server Configs (matches original McpServerConfig variants) ─────
export interface McpStdioServerConfig {
transport: McpTransport.Stdio;
command: string;
args: string[];
env: Record<string, string>;
}
export interface McpRemoteServerConfig {
transport: McpTransport.Sse | McpTransport.Http;
url: string;
headers: Record<string, string>;
headersHelper?: string;
oauth?: McpOAuthConfig;
}
export interface McpWebSocketServerConfig {
transport: McpTransport.Ws;
url: string;
headers: Record<string, string>;
headersHelper?: string;
}
export interface McpSdkServerConfig {
transport: McpTransport.Sdk;
name: string;
}
export interface McpManagedProxyServerConfig {
transport: McpTransport.ManagedProxy;
url: string;
id: string;
}
export interface McpOAuthConfig {
clientId?: string;
callbackPort?: number;
authServerMetadataUrl?: string;
xaa?: boolean;
}
export type McpServerConfig =
| McpStdioServerConfig
| McpRemoteServerConfig
| McpWebSocketServerConfig
| McpSdkServerConfig
| McpManagedProxyServerConfig;
export interface ScopedMcpServerConfig {
scope: ConfigSource;
config: McpServerConfig;
}
// ─── Hooks Config (matches original RuntimeHookConfig) ──────────────────
export interface RuntimeHookConfig {
preToolUse: string[];
postToolUse: string[];
}
// ─── Plugin Config (matches original RuntimePluginConfig) ───────────────
export interface RuntimePluginConfig {
enabledPlugins: Record<string, boolean>;
externalDirectories: string[];
installRoot?: string;
registryPath?: string;
bundledRoot?: string;
}
// ─── OAuth Config (matches original OAuthConfig) ────────────────────────
export interface OAuthConfig {
clientId: string;
authorizeUrl: string;
tokenUrl: string;
callbackPort?: number;
manualRedirectUrl?: string;
scopes: string[];
}
// ─── Sandbox Config (matches original SandboxConfig) ────────────────────
export interface SandboxConfig {
isolationMode?: "none" | "read_only" | "full";
}
// ─── Feature Config (matches original RuntimeFeatureConfig) ─────────────
export interface RuntimeFeatureConfig {
hooks: RuntimeHookConfig;
plugins: RuntimePluginConfig;
mcp: { servers: Record<string, ScopedMcpServerConfig> };
oauth?: OAuthConfig;
model?: string;
permissionMode?: ResolvedPermissionMode;
sandbox: SandboxConfig;
}
// ─── Runtime Config (matches original RuntimeConfig) ────────────────────
export interface RuntimeConfig {
merged: Record<string, any>;
loadedEntries: ConfigEntry[];
featureConfig: RuntimeFeatureConfig;
}
// ─── RuntimeConfig accessor helpers (match original Rust impl) ──────────
export function configHooks(config: RuntimeConfig): RuntimeHookConfig {
return config.featureConfig.hooks;
}
export function configPlugins(config: RuntimeConfig): RuntimePluginConfig {
return config.featureConfig.plugins;
}
export function configMcp(config: RuntimeConfig): { servers: Record<string, ScopedMcpServerConfig> } {
return config.featureConfig.mcp;
}
export function configOAuth(config: RuntimeConfig): OAuthConfig | undefined {
return config.featureConfig.oauth;
}
export function configModel(config: RuntimeConfig): string | undefined {
return config.featureConfig.model;
}
export function configPermissionMode(config: RuntimeConfig): ResolvedPermissionMode | undefined {
return config.featureConfig.permissionMode;
}
export function configSandbox(config: RuntimeConfig): SandboxConfig {
return config.featureConfig.sandbox;
}
/**
* Merge hooks config using extend_unique β€” matches original RuntimeHookConfig::merged()
*/
export function mergeHooksConfig(base: RuntimeHookConfig, other: RuntimeHookConfig): RuntimeHookConfig {
const merged = { preToolUse: [...base.preToolUse], postToolUse: [...base.postToolUse] };
extendUnique(merged.preToolUse, other.preToolUse);
extendUnique(merged.postToolUse, other.postToolUse);
return merged;
}
export function configWithHooks(config: RuntimeConfig, hooks: RuntimeHookConfig): RuntimeConfig {
return {
...config,
featureConfig: { ...config.featureConfig, hooks },
};
}
export function configWithPlugins(config: RuntimeConfig, plugins: RuntimePluginConfig): RuntimeConfig {
return {
...config,
featureConfig: { ...config.featureConfig, plugins },
};
}
export function configGet(config: RuntimeConfig, key: string): any {
return config.merged[key];
}
export function configAsJson(config: RuntimeConfig): Record<string, any> {
return { ...config.merged };
}
export function configEmpty(): RuntimeConfig {
return {
merged: {},
loadedEntries: [],
featureConfig: {
hooks: { preToolUse: [], postToolUse: [] },
plugins: { enabledPlugins: {}, externalDirectories: [] },
mcp: { servers: {} },
model: undefined,
permissionMode: undefined,
sandbox: {},
},
};
}
// ─── Default config home (matches original default_config_home) ─────────
function defaultConfigHome(): string {
const xdgConfig = process.env.XDG_CONFIG_HOME;
if (xdgConfig) return path.join(xdgConfig, "claw-code");
const home = process.env.HOME || "/home/ubuntu";
return path.join(home, ".config", "claw-code");
}
// ─── Config Loader (matches original ConfigLoader) ──────────────────────
export class ConfigLoader {
private cwd: string;
private configHome: string;
constructor(cwd: string, configHome?: string) {
this.cwd = cwd;
this.configHome = configHome || defaultConfigHome();
}
static defaultFor(cwd: string): ConfigLoader {
return new ConfigLoader(cwd, defaultConfigHome());
}
getConfigHome(): string {
return this.configHome;
}
/**
* Discover config file paths in priority order.
* Matches original discover() method:
* 1. ~/.claw.json (user legacy)
* 2. ~/.config/claw-code/settings.json (user)
* 3. <cwd>/.claw.json (project)
* 4. <cwd>/.claw/settings.json (project)
* 5. <cwd>/.claw/settings.local.json (local)
*/
discover(): ConfigEntry[] {
const userLegacyPath = path.join(
path.dirname(this.configHome),
"..",
".claw.json"
);
return [
{ source: ConfigSource.User, path: userLegacyPath },
{ source: ConfigSource.User, path: path.join(this.configHome, "settings.json") },
{ source: ConfigSource.Project, path: path.join(this.cwd, ".claw.json") },
{ source: ConfigSource.Project, path: path.join(this.cwd, ".claw", "settings.json") },
{ source: ConfigSource.Local, path: path.join(this.cwd, ".claw", "settings.local.json") },
];
}
/**
* Load and merge all config files.
* Matches original load() method.
*/
load(): RuntimeConfig {
const merged: Record<string, any> = {};
const loadedEntries: ConfigEntry[] = [];
const mcpServers: Record<string, ScopedMcpServerConfig> = {};
for (const entry of this.discover()) {
const value = readOptionalJsonObject(entry.path);
if (value === null) continue;
// Parse MCP servers
if (value.mcpServers && typeof value.mcpServers === "object") {
for (const [name, serverValue] of Object.entries(value.mcpServers)) {
const parsed = parseMcpServerConfig(name, serverValue as any);
if (parsed) {
mcpServers[name] = { scope: entry.source, config: parsed };
}
}
}
// Deep merge
deepMergeObjects(merged, value);
loadedEntries.push(entry);
}
const featureConfig: RuntimeFeatureConfig = {
hooks: parseOptionalHooksConfig(merged),
plugins: parseOptionalPluginConfig(merged),
mcp: { servers: mcpServers },
oauth: parseOptionalOAuthConfig(merged),
model: typeof merged.model === "string" ? merged.model : undefined,
permissionMode: parseOptionalPermissionMode(merged),
sandbox: parseOptionalSandboxConfig(merged),
};
return { merged, loadedEntries, featureConfig };
}
}
// ─── ConfigError (matches original ConfigError enum) ─────────────────────────────
export enum ConfigErrorKind {
FileNotFound = "file_not_found",
ParseError = "parse_error",
ValidationError = "validation_error",
IoError = "io_error",
}
export class ConfigError extends Error {
kind: ConfigErrorKind;
filePath?: string;
constructor(kind: ConfigErrorKind, message: string, filePath?: string) {
super(message);
this.kind = kind;
this.filePath = filePath;
this.name = "ConfigError";
}
static fileNotFound(filePath: string): ConfigError {
return new ConfigError(ConfigErrorKind.FileNotFound, `Config file not found: ${filePath}`, filePath);
}
static parseError(filePath: string, reason: string): ConfigError {
return new ConfigError(ConfigErrorKind.ParseError, `Failed to parse config ${filePath}: ${reason}`, filePath);
}
static validationError(message: string): ConfigError {
return new ConfigError(ConfigErrorKind.ValidationError, message);
}
}
// ─── RuntimeConfig accessor: loaded_entries() ──────────────────────────────
/**
* Get the list of loaded config entries with their sources.
* Matches original RuntimeConfig::loaded_entries().
*/
export function configLoadedEntries(config: RuntimeConfig): ConfigEntry[] {
return [...config.loadedEntries];
}
/**
* Full config serialization β€” matches original RuntimeConfig::as_json().
* Returns the complete merged config with feature config overlay.
*/
export function configAsJsonFull(config: RuntimeConfig): Record<string, any> {
return {
...config.merged,
_feature: {
hooks: config.featureConfig.hooks,
plugins: config.featureConfig.plugins,
mcp: {
servers: Object.fromEntries(
Object.entries(config.featureConfig.mcp.servers).map(([name, scoped]) => [
name,
{ scope: scoped.scope, ...scoped.config },
])
),
},
model: config.featureConfig.model,
permissionMode: config.featureConfig.permissionMode,
sandbox: config.featureConfig.sandbox,
},
_loaded: config.loadedEntries.map(e => ({ source: e.source, path: e.path })),
};
}
// ─── Singleton config instance ──────────────────────────────────────────────────────
let _cachedConfig: RuntimeConfig | null = null;
let _cachedCwd: string | null = null;
/**
* Get or load the runtime config for the given working directory.
* Caches the result until invalidated.
*/
export function getConfig(cwd: string): RuntimeConfig {
if (_cachedConfig && _cachedCwd === cwd) return _cachedConfig;
const loader = ConfigLoader.defaultFor(cwd);
_cachedConfig = loader.load();
_cachedCwd = cwd;
return _cachedConfig;
}
/**
* Invalidate the cached config (e.g., after /config set).
*/
export function invalidateConfigCache(): void {
_cachedConfig = null;
_cachedCwd = null;
}
/**
* Write a config value to the project-level settings file.
* Matches the /config set behavior.
*/
export function setConfigValue(cwd: string, key: string, value: any): void {
const configPath = path.join(cwd, ".claw", "settings.json");
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
let existing: Record<string, any> = {};
try {
const raw = fs.readFileSync(configPath, "utf-8");
existing = JSON.parse(raw);
} catch {
// File doesn't exist or invalid JSON
}
// Support dot-notation keys (e.g., "hooks.PreToolUse")
const keys = key.split(".");
let obj = existing;
for (let i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") {
obj[keys[i]] = {};
}
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
invalidateConfigCache();
}
/**
* Get a config value by dot-notation key.
*/
export function getConfigValue(cwd: string, key: string): any {
const config = getConfig(cwd);
const keys = key.split(".");
let obj: any = config.merged;
for (const k of keys) {
if (obj === undefined || obj === null) return undefined;
obj = obj[k];
}
return obj;
}
// ─── Helper Functions ───────────────────────────────────────────────────
function readOptionalJsonObject(filePath: string): Record<string, any> | null {
try {
const contents = fs.readFileSync(filePath, "utf-8");
if (!contents.trim()) return {};
const parsed = JSON.parse(contents);
if (typeof parsed !== "object" || Array.isArray(parsed)) {
// Legacy .claw.json files might not be objects
if (filePath.endsWith(".claw.json")) return null;
return null;
}
return parsed;
} catch {
return null;
}
}
function deepMergeObjects(target: Record<string, any>, source: Record<string, any>): void {
for (const [key, value] of Object.entries(source)) {
if (
key !== "mcpServers" && // MCP servers handled separately
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
target[key] &&
typeof target[key] === "object" &&
!Array.isArray(target[key])
) {
deepMergeObjects(target[key], value);
} else {
target[key] = value;
}
}
}
function parseMcpServerConfig(name: string, value: any): McpServerConfig | null {
if (!value || typeof value !== "object") return null;
// Determine transport type
if (value.command) {
return {
transport: McpTransport.Stdio,
command: value.command,
args: Array.isArray(value.args) ? value.args : [],
env: value.env && typeof value.env === "object" ? value.env : {},
};
}
if (value.url) {
const transport = value.transport || "sse";
if (transport === "ws" || transport === "websocket") {
return {
transport: McpTransport.Ws,
url: value.url,
headers: value.headers || {},
headersHelper: value.headersHelper,
};
}
if (transport === "managed_proxy") {
return {
transport: McpTransport.ManagedProxy,
url: value.url,
id: value.id || name,
};
}
return {
transport: transport === "http" ? McpTransport.Http : McpTransport.Sse,
url: value.url,
headers: value.headers || {},
headersHelper: value.headersHelper,
oauth: value.oauth
? {
clientId: value.oauth.clientId,
callbackPort: value.oauth.callbackPort,
authServerMetadataUrl: value.oauth.authServerMetadataUrl,
xaa: value.oauth.xaa,
}
: undefined,
};
}
if (value.name) {
return { transport: McpTransport.Sdk, name: value.name };
}
return null;
}
// ─── extend_unique / push_unique (matches original config.rs) ──────────────
function pushUnique(target: string[], value: string): void {
if (!target.includes(value)) {
target.push(value);
}
}
function extendUnique(target: string[], values: string[]): void {
for (const value of values) {
pushUnique(target, value);
}
}
function parseOptionalHooksConfig(merged: Record<string, any>): RuntimeHookConfig {
const hooks = merged.hooks;
if (!hooks || typeof hooks !== "object") {
return { preToolUse: [], postToolUse: [] };
}
// Support all naming conventions: PascalCase (original), snake_case, camelCase
const pre = hooks.PreToolUse || hooks.pre_tool_use || hooks.preToolUse || [];
const post = hooks.PostToolUse || hooks.post_tool_use || hooks.postToolUse || [];
return {
preToolUse: Array.isArray(pre) ? pre.filter((s: any) => typeof s === "string") : [],
postToolUse: Array.isArray(post) ? post.filter((s: any) => typeof s === "string") : [],
};
}
function parseOptionalPluginConfig(merged: Record<string, any>): RuntimePluginConfig {
const config: RuntimePluginConfig = {
enabledPlugins: {},
externalDirectories: [],
};
if (merged.enabledPlugins && typeof merged.enabledPlugins === "object") {
for (const [k, v] of Object.entries(merged.enabledPlugins)) {
if (typeof v === "boolean") config.enabledPlugins[k] = v;
}
}
const plugins = merged.plugins;
if (!plugins || typeof plugins !== "object") return config;
if (plugins.enabled && typeof plugins.enabled === "object") {
for (const [k, v] of Object.entries(plugins.enabled)) {
if (typeof v === "boolean") config.enabledPlugins[k] = v;
}
}
if (Array.isArray(plugins.externalDirectories)) {
config.externalDirectories = plugins.externalDirectories.filter((s: any) => typeof s === "string");
}
if (typeof plugins.installRoot === "string") config.installRoot = plugins.installRoot;
if (typeof plugins.registryPath === "string") config.registryPath = plugins.registryPath;
if (typeof plugins.bundledRoot === "string") config.bundledRoot = plugins.bundledRoot;
return config;
}
function parseOptionalOAuthConfig(merged: Record<string, any>): OAuthConfig | undefined {
const oauth = merged.oauth;
if (!oauth || typeof oauth !== "object") return undefined;
if (!oauth.clientId || !oauth.authorizeUrl || !oauth.tokenUrl) return undefined;
return {
clientId: oauth.clientId,
authorizeUrl: oauth.authorizeUrl,
tokenUrl: oauth.tokenUrl,
callbackPort: oauth.callbackPort,
manualRedirectUrl: oauth.manualRedirectUrl,
scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [],
};
}
function parseOptionalPermissionMode(merged: Record<string, any>): ResolvedPermissionMode | undefined {
const mode = merged.permissions?.mode || merged.permissionMode;
if (!mode || typeof mode !== "string") return undefined;
const normalized = mode.toLowerCase().replace(/[-_\s]/g, "");
if (normalized === "readonly" || normalized === "readOnly") return ResolvedPermissionMode.ReadOnly;
if (normalized === "workspacewrite" || normalized === "workspace") return ResolvedPermissionMode.WorkspaceWrite;
if (normalized === "fullaccess" || normalized === "danger" || normalized === "dangerfullaccess") return ResolvedPermissionMode.DangerFullAccess;
return undefined;
}
function parseOptionalSandboxConfig(merged: Record<string, any>): SandboxConfig {
const sandbox = merged.sandbox;
if (!sandbox || typeof sandbox !== "object") return {};
return {
isolationMode: sandbox.isolationMode,
};
}