Files
openclaw/src/gateway/server-methods/config.ts
Val Alexander f76a3c5225 feat(ui): dashboard-v2 views refactor (slice 3/3 of dashboard-v2) (#41503)
* feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)

New self-contained chat modules extracted from dashboard-v2-structure:

- chat/slash-commands.ts: slash command definitions and completions
- chat/slash-command-executor.ts: execute slash commands via gateway RPC
- chat/slash-command-executor.node.test.ts: test coverage
- chat/speech.ts: speech-to-text (STT) support
- chat/input-history.ts: per-session input history navigation
- chat/pinned-messages.ts: pinned message management
- chat/deleted-messages.ts: deleted message tracking
- chat/export.ts: shared exportChatMarkdown helper
- chat-export.ts: re-export shim for backwards compat

Gateway fix:
- Restore usage/cost stripping in chat.history sanitization
- Add test coverage for sanitization behavior

These modules are additive and tree-shaken — no existing code
imports them yet. They will be wired in subsequent slices.

* feat(ui): add utilities, theming, and i18n updates (slice 2 of dashboard-v2)

UI utilities and theming improvements extracted from dashboard-v2-structure:

Icons & formatting:
- icons.ts: expanded icon set for new dashboard views
- format.ts: date/number formatting helpers
- tool-labels.ts: human-readable tool name mappings

Theming:
- theme.ts: enhanced theme resolution and system theme support
- theme-transition.ts: simplified transition logic
- storage.ts: theme parsing improvements for settings persistence

Navigation & types:
- navigation.ts: extended tab definitions for dashboard-v2
- app-view-state.ts: expanded view state management
- types.ts: new type definitions (HealthSummary, ModelCatalogEntry, etc.)

Components:
- components/dashboard-header.ts: reusable header component

i18n:
- Updated en, pt-BR, zh-CN, zh-TW locales with new dashboard strings

All changes are additive or backwards-compatible. Build passes.
Part of #36853.

* feat(ui): dashboard-v2 views refactor (slice 3 of dashboard-v2)

Complete views refactor from dashboard-v2-structure, building on
slice 1 (chat infra, #41497) and slice 2 (utilities/theming, #41500).

Core app wiring:
- app.ts: updated host component with new state properties
- app-render.ts: refactored render pipeline for new dashboard layout
- app-render.helpers.ts: extracted render helpers
- app-settings.ts: theme listener lifecycle fix, cron runs on tab load
- app-gateway.ts: refactored chat event handling
- app-chat.ts: slash command integration

New views:
- views/command-palette.ts: command palette (Cmd+K)
- views/login-gate.ts: authentication gate
- views/bottom-tabs.ts: mobile tab navigation
- views/overview-*.ts: modular overview dashboard (cards, attention,
  event log, hints, log tail, quick actions)
- views/agents-panels-overview.ts: agent overview panel

Refactored views:
- views/chat.ts: major refactor with STT, slash commands, search,
  export, pinned messages, input history
- views/config.ts: restructured config management
- views/agents.ts: streamlined agent management
- views/overview.ts: modular composition from sub-views
- views/sessions.ts: enhanced session management

Controllers:
- controllers/health.ts: new health check controller
- controllers/models.ts: new model catalog controller
- controllers/agents.ts: tools catalog improvements
- controllers/config.ts: config form enhancements

Tests & infrastructure:
- Updated test helpers, browser tests, node tests
- vite.config.ts: build configuration updates
- markdown.ts: rendering improvements

Build passes  | 44 files | +6,626/-1,499
Part of #36853. Depends on #41497 and #41500.

* UI: fix chat review follow-ups

* fix(ui): repair chat clear and attachment regressions

* fix(ui): address remaining chat review comments

* fix(ui): address review follow-ups

* fix(ui): replay queued local slash commands

* fix(ui): repair control-ui type drift

* fix(ui): restore control UI styling

* feat(ui): enhance layout and styling for config and topbar components

- Updated grid layout for the config layout to allow full-width usage.
- Introduced new styles for top tabs and search components to improve usability.
- Added theme mode toggle styling for better visual integration.
- Implemented tests for layout and theme mode components to ensure proper rendering and functionality.

* feat(ui): add config file opening functionality and enhance styles

- Implemented a new handler to open the configuration file using the default application based on the operating system.
- Updated various CSS styles across components for improved visual consistency and usability, including adjustments to padding, margins, and font sizes.
- Introduced new styles for the data table and sidebar components to enhance layout and interaction.
- Added tests for the collapsed navigation rail to ensure proper functionality in different states.

* refactor(ui): update CSS styles for improved layout and consistency

- Simplified font-body declaration in base.css for cleaner code.
- Adjusted transition properties in components.css for better readability.
- Added new .workspace-link class in components.css for enhanced link styling.
- Changed config layout from grid to flex in config.css for better responsiveness.
- Updated related tests to reflect layout changes in config-layout.browser.test.ts.

* feat(ui): enhance theme handling and loading states in chat interface

- Updated CSS to support new theme mode attributes for better styling consistency across light and dark themes.
- Introduced loading skeletons in the chat view to improve user experience during data fetching.
- Refactored command palette to manage focus more effectively, enhancing accessibility.
- Added tests for the appearance theme picker and loading states to ensure proper rendering and functionality.

* refactor(ui): streamline ephemeral state management in chat and config views

- Introduced interfaces for ephemeral state in chat and config views to encapsulate related variables.
- Refactored state management to utilize a single object for better organization and maintainability.
- Removed legacy state variables and updated related functions to reference the new state structure.
- Enhanced readability and consistency across the codebase by standardizing state handling.

* chore: remove test files to reduce PR scope

* fix(ui): resolve type errors in debug props and chat search

* refactor(ui): remove stream mode functionality across various components

- Eliminated stream mode related translations and CSS styles to streamline the user interface.
- Updated multiple components to remove references to stream mode, enhancing code clarity and maintainability.
- Adjusted rendering logic in views to ensure consistent behavior without stream mode.
- Improved overall readability by cleaning up unused variables and props.

* fix(ui): add msg-meta CSS and fix rebase type errors

* fix(ui): add CSS for chat footer action buttons (TTS, delete) and msg-meta

* feat(ui): add delete confirmation with remember-decision checkbox

* fix(ui): delete confirmation with remember, attention icon sizing

* fix(ui): open delete confirm popover to the left (not clipped)

* fix(ui): show all nav items in collapsed sidebar, remove gap

* fix(ui): address P1/P2 review feedback — session queue clear, kill scope, palette guard, stop button

* fix(ui): address Greptile re-review — kill scope, queue flush, idle handling, parallel fetch

- SECURITY: /kill <target> now enforces session tree scope (not just /kill all)
- /kill reports idle sessions gracefully instead of throwing
- Queue continues draining after local slash commands
- /model fetches sessions.list + models.list in parallel (perf fix)

* fix(ui): style update banner close button — SVG stroke + sizing

* fix(ui): update layout styles for sidebar and content spacing

* UI: restore colon slash command parsing

* UI: restore slash command session queries

* Refactor thinking resolution: Introduce resolveThinkingDefaultForModel function and update model-selection to utilize it. Add tests for new functionality in thinking.test.ts.

* fix(ui): constrain welcome state logo size, add missing CSS for new session view

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-12 12:46:19 -05:00

549 lines
17 KiB
TypeScript

import { exec } from "node:child_process";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import {
createConfigIO,
loadConfig,
parseConfigJson5,
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
resolveConfigSnapshotHash,
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { applyLegacyMigrations } from "../../config/legacy.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import {
redactConfigObject,
redactConfigSnapshot,
restoreRedactedValues,
} from "../../config/redact-snapshot.js";
import {
buildConfigSchema,
lookupConfigSchema,
type ConfigSchemaResponse,
} from "../../config/schema.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import type { ConfigValidationIssue, OpenClawConfig } from "../../config/types.openclaw.js";
import {
formatDoctorNonInteractiveHint,
type RestartSentinelPayload,
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import { diffConfigPaths } from "../config-reload.js";
import {
formatControlPlaneActor,
resolveControlPlaneActor,
summarizeChangedPaths,
} from "../control-plane-audit.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateConfigApplyParams,
validateConfigGetParams,
validateConfigPatchParams,
validateConfigSchemaLookupParams,
validateConfigSchemaLookupResult,
validateConfigSchemaParams,
validateConfigSetParams,
} from "../protocol/index.js";
import { resolveBaseHashParam } from "./base-hash.js";
import { parseRestartRequestParams } from "./restart-request.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
const MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE = 3;
function requireConfigBaseHash(
params: unknown,
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
respond: RespondFn,
): boolean {
if (!snapshot.exists) {
return true;
}
const snapshotHash = resolveConfigSnapshotHash(snapshot);
if (!snapshotHash) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"config base hash unavailable; re-run config.get and retry",
),
);
return false;
}
const baseHash = resolveBaseHashParam(params);
if (!baseHash) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"config base hash required; re-run config.get and retry",
),
);
return false;
}
if (baseHash !== snapshotHash) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"config changed since last load; re-run config.get and retry",
),
);
return false;
}
return true;
}
function parseRawConfigOrRespond(
params: unknown,
requestName: string,
respond: RespondFn,
): string | null {
const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid ${requestName} params: raw (string) required`,
),
);
return null;
}
return rawValue;
}
function sanitizeLookupPathForLog(path: string): string {
const sanitized = Array.from(path, (char) => {
const code = char.charCodeAt(0);
return code < 0x20 || code === 0x7f ? "?" : char;
}).join("");
return sanitized.length > 120 ? `${sanitized.slice(0, 117)}...` : sanitized;
}
function parseValidateConfigFromRawOrRespond(
params: unknown,
requestName: string,
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
respond: RespondFn,
): { config: OpenClawConfig; schema: ConfigSchemaResponse } | null {
const rawValue = parseRawConfigOrRespond(params, requestName, respond);
if (!rawValue) {
return null;
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
return null;
}
const schema = loadSchemaWithPlugins();
const restored = restoreRedactedValues(parsedRes.parsed, snapshot.config, schema.uiHints);
if (!restored.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, restored.humanReadableMessage ?? "invalid config"),
);
return null;
}
const validated = validateConfigObjectWithPlugins(restored.result);
if (!validated.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(validated.issues), {
details: { issues: validated.issues },
}),
);
return null;
}
return { config: validated.config, schema };
}
function summarizeConfigValidationIssues(issues: ReadonlyArray<ConfigValidationIssue>): string {
const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE);
const lines = formatConfigIssueLines(trimmed, "", { normalizeRoot: true })
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return "invalid config";
}
const hiddenCount = Math.max(0, issues.length - lines.length);
return `invalid config: ${lines.join("; ")}${
hiddenCount > 0 ? ` (+${hiddenCount} more issue${hiddenCount === 1 ? "" : "s"})` : ""
}`;
}
function resolveConfigRestartRequest(params: unknown): {
sessionKey: string | undefined;
note: string | undefined;
restartDelayMs: number | undefined;
deliveryContext: ReturnType<typeof extractDeliveryInfo>["deliveryContext"];
threadId: ReturnType<typeof extractDeliveryInfo>["threadId"];
} {
const { sessionKey, note, restartDelayMs } = parseRestartRequestParams(params);
// Extract deliveryContext + threadId for routing after restart
// Supports both :thread: (most channels) and :topic: (Telegram)
const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey);
return {
sessionKey,
note,
restartDelayMs,
deliveryContext,
threadId,
};
}
function buildConfigRestartSentinelPayload(params: {
kind: RestartSentinelPayload["kind"];
mode: string;
sessionKey: string | undefined;
deliveryContext: ReturnType<typeof extractDeliveryInfo>["deliveryContext"];
threadId: ReturnType<typeof extractDeliveryInfo>["threadId"];
note: string | undefined;
}): RestartSentinelPayload {
const configPath = createConfigIO().configPath;
return {
kind: params.kind,
status: "ok",
ts: Date.now(),
sessionKey: params.sessionKey,
deliveryContext: params.deliveryContext,
threadId: params.threadId,
message: params.note ?? null,
doctorHint: formatDoctorNonInteractiveHint(),
stats: {
mode: params.mode,
root: configPath,
},
};
}
async function tryWriteRestartSentinelPayload(
payload: RestartSentinelPayload,
): Promise<string | null> {
try {
return await writeRestartSentinel(payload);
} catch {
return null;
}
}
function loadSchemaWithPlugins(): ConfigSchemaResponse {
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const pluginRegistry = loadOpenClawPlugins({
config: cfg,
cache: true,
workspaceDir,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
});
// Note: We can't easily cache this, as there are no callback that can invalidate
// our cache. However, both loadConfig() and loadOpenClawPlugins() already cache
// their results, and buildConfigSchema() is just a cheap transformation.
return buildConfigSchema({
plugins: pluginRegistry.plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
configUiHints: plugin.configUiHints,
configSchema: plugin.configJsonSchema,
})),
channels: listChannelPlugins().map((entry) => ({
id: entry.id,
label: entry.meta.label,
description: entry.meta.blurb,
configSchema: entry.configSchema?.schema,
configUiHints: entry.configSchema?.uiHints,
})),
});
}
export const configHandlers: GatewayRequestHandlers = {
"config.get": async ({ params, respond }) => {
if (!assertValidParams(params, validateConfigGetParams, "config.get", respond)) {
return;
}
const snapshot = await readConfigFileSnapshot();
const schema = loadSchemaWithPlugins();
respond(true, redactConfigSnapshot(snapshot, schema.uiHints), undefined);
},
"config.schema": ({ params, respond }) => {
if (!assertValidParams(params, validateConfigSchemaParams, "config.schema", respond)) {
return;
}
respond(true, loadSchemaWithPlugins(), undefined);
},
"config.schema.lookup": ({ params, respond, context }) => {
if (
!assertValidParams(params, validateConfigSchemaLookupParams, "config.schema.lookup", respond)
) {
return;
}
const path = (params as { path: string }).path;
const schema = loadSchemaWithPlugins();
const result = lookupConfigSchema(schema, path);
if (!result) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "config schema path not found"),
);
return;
}
if (!validateConfigSchemaLookupResult(result)) {
const errors = validateConfigSchemaLookupResult.errors ?? [];
context.logGateway.warn(
`config.schema.lookup produced invalid payload for ${sanitizeLookupPathForLog(path)}: ${formatValidationErrors(errors)}`,
);
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "config.schema.lookup returned invalid payload", {
details: { errors },
}),
);
return;
}
respond(true, result, undefined);
},
"config.set": async ({ params, respond }) => {
if (!assertValidParams(params, validateConfigSetParams, "config.set", respond)) {
return;
}
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
if (!requireConfigBaseHash(params, snapshot, respond)) {
return;
}
const parsed = parseValidateConfigFromRawOrRespond(params, "config.set", snapshot, respond);
if (!parsed) {
return;
}
await writeConfigFile(parsed.config, writeOptions);
respond(
true,
{
ok: true,
path: createConfigIO().configPath,
config: redactConfigObject(parsed.config, parsed.schema.uiHints),
},
undefined,
);
},
"config.patch": async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateConfigPatchParams, "config.patch", respond)) {
return;
}
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
if (!requireConfigBaseHash(params, snapshot, respond)) {
return;
}
if (!snapshot.valid) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config; fix before patching"),
);
return;
}
const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid config.patch params: raw (string) required",
),
);
return;
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
return;
}
if (
!parsedRes.parsed ||
typeof parsedRes.parsed !== "object" ||
Array.isArray(parsedRes.parsed)
) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "config.patch raw must be an object"),
);
return;
}
const merged = applyMergePatch(snapshot.config, parsedRes.parsed, {
mergeObjectArraysById: true,
});
const schemaPatch = loadSchemaWithPlugins();
const restoredMerge = restoreRedactedValues(merged, snapshot.config, schemaPatch.uiHints);
if (!restoredMerge.ok) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
restoredMerge.humanReadableMessage ?? "invalid config",
),
);
return;
}
const migrated = applyLegacyMigrations(restoredMerge.result);
const resolved = migrated.next ?? restoredMerge.result;
const validated = validateConfigObjectWithPlugins(resolved);
if (!validated.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(validated.issues), {
details: { issues: validated.issues },
}),
);
return;
}
const changedPaths = diffConfigPaths(snapshot.config, validated.config);
const actor = resolveControlPlaneActor(client);
context?.logGateway?.info(
`config.patch write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.patch`,
);
await writeConfigFile(validated.config, writeOptions);
const { sessionKey, note, restartDelayMs, deliveryContext, threadId } =
resolveConfigRestartRequest(params);
const payload = buildConfigRestartSentinelPayload({
kind: "config-patch",
mode: "config.patch",
sessionKey,
deliveryContext,
threadId,
note,
});
const sentinelPath = await tryWriteRestartSentinelPayload(payload);
const restart = scheduleGatewaySigusr1Restart({
delayMs: restartDelayMs,
reason: "config.patch",
audit: {
actor: actor.actor,
deviceId: actor.deviceId,
clientIp: actor.clientIp,
changedPaths,
},
});
if (restart.coalesced) {
context?.logGateway?.warn(
`config.patch restart coalesced ${formatControlPlaneActor(actor)} delayMs=${restart.delayMs}`,
);
}
respond(
true,
{
ok: true,
path: createConfigIO().configPath,
config: redactConfigObject(validated.config, schemaPatch.uiHints),
restart,
sentinel: {
path: sentinelPath,
payload,
},
},
undefined,
);
},
"config.apply": async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateConfigApplyParams, "config.apply", respond)) {
return;
}
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
if (!requireConfigBaseHash(params, snapshot, respond)) {
return;
}
const parsed = parseValidateConfigFromRawOrRespond(params, "config.apply", snapshot, respond);
if (!parsed) {
return;
}
const changedPaths = diffConfigPaths(snapshot.config, parsed.config);
const actor = resolveControlPlaneActor(client);
context?.logGateway?.info(
`config.apply write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.apply`,
);
await writeConfigFile(parsed.config, writeOptions);
const { sessionKey, note, restartDelayMs, deliveryContext, threadId } =
resolveConfigRestartRequest(params);
const payload = buildConfigRestartSentinelPayload({
kind: "config-apply",
mode: "config.apply",
sessionKey,
deliveryContext,
threadId,
note,
});
const sentinelPath = await tryWriteRestartSentinelPayload(payload);
const restart = scheduleGatewaySigusr1Restart({
delayMs: restartDelayMs,
reason: "config.apply",
audit: {
actor: actor.actor,
deviceId: actor.deviceId,
clientIp: actor.clientIp,
changedPaths,
},
});
if (restart.coalesced) {
context?.logGateway?.warn(
`config.apply restart coalesced ${formatControlPlaneActor(actor)} delayMs=${restart.delayMs}`,
);
}
respond(
true,
{
ok: true,
path: createConfigIO().configPath,
config: redactConfigObject(parsed.config, parsed.schema.uiHints),
restart,
sentinel: {
path: sentinelPath,
payload,
},
},
undefined,
);
},
"config.openFile": ({ params, respond }) => {
if (!assertValidParams(params, validateConfigGetParams, "config.openFile", respond)) {
return;
}
const configPath = createConfigIO().configPath;
const platform = process.platform;
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
exec(`${cmd} ${JSON.stringify(configPath)}`, (err) => {
if (err) {
respond(true, { ok: false, path: configPath, error: err.message }, undefined);
return;
}
respond(true, { ok: true, path: configPath }, undefined);
});
},
};