mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-09 15:35:17 +00:00
Config UI: tag filters and complete schema help/labels coverage (#23796)
* Config UI: add tag filters and complete schema help/labels * Config UI: finalize tags/help polish and unblock test suite * Protocol: regenerate Swift gateway models
This commit is contained in:
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen.
|
||||
- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence.
|
||||
- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.
|
||||
- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead.
|
||||
|
||||
@@ -2381,6 +2381,9 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let status: AnyCodable?
|
||||
public let error: String?
|
||||
public let summary: String?
|
||||
public let delivered: Bool?
|
||||
public let deliverystatus: AnyCodable?
|
||||
public let deliveryerror: String?
|
||||
public let sessionid: String?
|
||||
public let sessionkey: String?
|
||||
public let runatms: Int?
|
||||
@@ -2394,6 +2397,9 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
status: AnyCodable?,
|
||||
error: String?,
|
||||
summary: String?,
|
||||
delivered: Bool?,
|
||||
deliverystatus: AnyCodable?,
|
||||
deliveryerror: String?,
|
||||
sessionid: String?,
|
||||
sessionkey: String?,
|
||||
runatms: Int?,
|
||||
@@ -2406,6 +2412,9 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.summary = summary
|
||||
self.delivered = delivered
|
||||
self.deliverystatus = deliverystatus
|
||||
self.deliveryerror = deliveryerror
|
||||
self.sessionid = sessionid
|
||||
self.sessionkey = sessionkey
|
||||
self.runatms = runatms
|
||||
@@ -2420,6 +2429,9 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
case status
|
||||
case error
|
||||
case summary
|
||||
case delivered
|
||||
case deliverystatus = "deliveryStatus"
|
||||
case deliveryerror = "deliveryError"
|
||||
case sessionid = "sessionId"
|
||||
case sessionkey = "sessionKey"
|
||||
case runatms = "runAtMs"
|
||||
|
||||
@@ -2381,6 +2381,9 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let status: AnyCodable?
|
||||
public let error: String?
|
||||
public let summary: String?
|
||||
public let delivered: Bool?
|
||||
public let deliverystatus: AnyCodable?
|
||||
public let deliveryerror: String?
|
||||
public let sessionid: String?
|
||||
public let sessionkey: String?
|
||||
public let runatms: Int?
|
||||
@@ -2394,6 +2397,9 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
status: AnyCodable?,
|
||||
error: String?,
|
||||
summary: String?,
|
||||
delivered: Bool?,
|
||||
deliverystatus: AnyCodable?,
|
||||
deliveryerror: String?,
|
||||
sessionid: String?,
|
||||
sessionkey: String?,
|
||||
runatms: Int?,
|
||||
@@ -2406,6 +2412,9 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.summary = summary
|
||||
self.delivered = delivered
|
||||
self.deliverystatus = deliverystatus
|
||||
self.deliveryerror = deliveryerror
|
||||
self.sessionid = sessionid
|
||||
self.sessionkey = sessionkey
|
||||
self.runatms = runatms
|
||||
@@ -2420,6 +2429,9 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
case status
|
||||
case error
|
||||
case summary
|
||||
case delivered
|
||||
case deliverystatus = "deliveryStatus"
|
||||
case deliveryerror = "deliveryError"
|
||||
case sessionid = "sessionId"
|
||||
case sessionkey = "sessionKey"
|
||||
case runatms = "runAtMs"
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
export type ChannelConfigUiHint = {
|
||||
label?: string;
|
||||
help?: string;
|
||||
tags?: string[];
|
||||
advanced?: boolean;
|
||||
sensitive?: boolean;
|
||||
placeholder?: string;
|
||||
|
||||
763
src/config/schema.help.quality.test.ts
Normal file
763
src/config/schema.help.quality.test.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FIELD_HELP } from "./schema.help.js";
|
||||
import { FIELD_LABELS } from "./schema.labels.js";
|
||||
|
||||
const ROOT_SECTIONS = [
|
||||
"meta",
|
||||
"env",
|
||||
"wizard",
|
||||
"diagnostics",
|
||||
"logging",
|
||||
"update",
|
||||
"browser",
|
||||
"ui",
|
||||
"auth",
|
||||
"models",
|
||||
"nodeHost",
|
||||
"agents",
|
||||
"tools",
|
||||
"bindings",
|
||||
"broadcast",
|
||||
"audio",
|
||||
"media",
|
||||
"messages",
|
||||
"commands",
|
||||
"approvals",
|
||||
"session",
|
||||
"cron",
|
||||
"hooks",
|
||||
"web",
|
||||
"channels",
|
||||
"discovery",
|
||||
"canvasHost",
|
||||
"talk",
|
||||
"gateway",
|
||||
"memory",
|
||||
"plugins",
|
||||
] as const;
|
||||
|
||||
const TARGET_KEYS = [
|
||||
"memory.citations",
|
||||
"memory.backend",
|
||||
"memory.qmd.searchMode",
|
||||
"memory.qmd.scope",
|
||||
"memory.qmd.includeDefaultMemory",
|
||||
"memory.qmd.mcporter.enabled",
|
||||
"memory.qmd.mcporter.serverName",
|
||||
"memory.qmd.command",
|
||||
"memory.qmd.mcporter",
|
||||
"memory.qmd.mcporter.startDaemon",
|
||||
"memory.qmd.paths",
|
||||
"memory.qmd.paths.path",
|
||||
"memory.qmd.paths.pattern",
|
||||
"memory.qmd.paths.name",
|
||||
"memory.qmd.sessions.enabled",
|
||||
"memory.qmd.sessions.exportDir",
|
||||
"memory.qmd.sessions.retentionDays",
|
||||
"memory.qmd.update.interval",
|
||||
"memory.qmd.update.debounceMs",
|
||||
"memory.qmd.update.onBoot",
|
||||
"memory.qmd.update.waitForBootSync",
|
||||
"memory.qmd.update.embedInterval",
|
||||
"memory.qmd.update.commandTimeoutMs",
|
||||
"memory.qmd.update.updateTimeoutMs",
|
||||
"memory.qmd.update.embedTimeoutMs",
|
||||
"memory.qmd.limits.maxResults",
|
||||
"memory.qmd.limits.maxSnippetChars",
|
||||
"memory.qmd.limits.maxInjectedChars",
|
||||
"memory.qmd.limits.timeoutMs",
|
||||
"agents.defaults.memorySearch.provider",
|
||||
"agents.defaults.memorySearch.fallback",
|
||||
"agents.defaults.memorySearch.sources",
|
||||
"agents.defaults.memorySearch.extraPaths",
|
||||
"agents.defaults.memorySearch.experimental.sessionMemory",
|
||||
"agents.defaults.memorySearch.remote.baseUrl",
|
||||
"agents.defaults.memorySearch.remote.apiKey",
|
||||
"agents.defaults.memorySearch.remote.headers",
|
||||
"agents.defaults.memorySearch.remote.batch.enabled",
|
||||
"agents.defaults.memorySearch.remote.batch.wait",
|
||||
"agents.defaults.memorySearch.remote.batch.concurrency",
|
||||
"agents.defaults.memorySearch.remote.batch.pollIntervalMs",
|
||||
"agents.defaults.memorySearch.remote.batch.timeoutMinutes",
|
||||
"agents.defaults.memorySearch.local.modelPath",
|
||||
"agents.defaults.memorySearch.store.path",
|
||||
"agents.defaults.memorySearch.store.vector.enabled",
|
||||
"agents.defaults.memorySearch.store.vector.extensionPath",
|
||||
"agents.defaults.memorySearch.query.hybrid.enabled",
|
||||
"agents.defaults.memorySearch.query.hybrid.vectorWeight",
|
||||
"agents.defaults.memorySearch.query.hybrid.textWeight",
|
||||
"agents.defaults.memorySearch.query.hybrid.candidateMultiplier",
|
||||
"agents.defaults.memorySearch.query.hybrid.mmr.enabled",
|
||||
"agents.defaults.memorySearch.query.hybrid.mmr.lambda",
|
||||
"agents.defaults.memorySearch.query.hybrid.temporalDecay.enabled",
|
||||
"agents.defaults.memorySearch.query.hybrid.temporalDecay.halfLifeDays",
|
||||
"agents.defaults.memorySearch.cache.enabled",
|
||||
"agents.defaults.memorySearch.cache.maxEntries",
|
||||
"agents.defaults.memorySearch.sync.onSearch",
|
||||
"agents.defaults.memorySearch.sync.watch",
|
||||
"agents.defaults.memorySearch.sync.sessions.deltaBytes",
|
||||
"agents.defaults.memorySearch.sync.sessions.deltaMessages",
|
||||
"models.mode",
|
||||
"models.providers.*.auth",
|
||||
"models.providers.*.authHeader",
|
||||
"gateway.reload.mode",
|
||||
"gateway.controlUi.allowInsecureAuth",
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth",
|
||||
"cron",
|
||||
"cron.enabled",
|
||||
"cron.store",
|
||||
"cron.maxConcurrentRuns",
|
||||
"cron.webhook",
|
||||
"cron.webhookToken",
|
||||
"cron.sessionRetention",
|
||||
"session",
|
||||
"session.scope",
|
||||
"session.dmScope",
|
||||
"session.identityLinks",
|
||||
"session.resetTriggers",
|
||||
"session.idleMinutes",
|
||||
"session.reset",
|
||||
"session.reset.mode",
|
||||
"session.reset.atHour",
|
||||
"session.reset.idleMinutes",
|
||||
"session.resetByType",
|
||||
"session.resetByType.direct",
|
||||
"session.resetByType.dm",
|
||||
"session.resetByType.group",
|
||||
"session.resetByType.thread",
|
||||
"session.resetByChannel",
|
||||
"session.store",
|
||||
"session.typingIntervalSeconds",
|
||||
"session.typingMode",
|
||||
"session.mainKey",
|
||||
"session.sendPolicy",
|
||||
"session.sendPolicy.default",
|
||||
"session.sendPolicy.rules",
|
||||
"session.sendPolicy.rules[].action",
|
||||
"session.sendPolicy.rules[].match",
|
||||
"session.sendPolicy.rules[].match.channel",
|
||||
"session.sendPolicy.rules[].match.chatType",
|
||||
"session.sendPolicy.rules[].match.keyPrefix",
|
||||
"session.sendPolicy.rules[].match.rawKeyPrefix",
|
||||
"session.agentToAgent",
|
||||
"session.agentToAgent.maxPingPongTurns",
|
||||
"session.threadBindings",
|
||||
"session.threadBindings.enabled",
|
||||
"session.threadBindings.ttlHours",
|
||||
"session.maintenance",
|
||||
"session.maintenance.mode",
|
||||
"session.maintenance.pruneAfter",
|
||||
"session.maintenance.pruneDays",
|
||||
"session.maintenance.maxEntries",
|
||||
"session.maintenance.rotateBytes",
|
||||
"approvals",
|
||||
"approvals.exec",
|
||||
"approvals.exec.enabled",
|
||||
"approvals.exec.mode",
|
||||
"approvals.exec.agentFilter",
|
||||
"approvals.exec.sessionFilter",
|
||||
"approvals.exec.targets",
|
||||
"approvals.exec.targets[].channel",
|
||||
"approvals.exec.targets[].to",
|
||||
"approvals.exec.targets[].accountId",
|
||||
"approvals.exec.targets[].threadId",
|
||||
"nodeHost",
|
||||
"nodeHost.browserProxy",
|
||||
"nodeHost.browserProxy.enabled",
|
||||
"nodeHost.browserProxy.allowProfiles",
|
||||
"media",
|
||||
"media.preserveFilenames",
|
||||
"audio",
|
||||
"audio.transcription",
|
||||
"audio.transcription.command",
|
||||
"audio.transcription.timeoutSeconds",
|
||||
"bindings",
|
||||
"bindings[].agentId",
|
||||
"bindings[].match",
|
||||
"bindings[].match.channel",
|
||||
"bindings[].match.accountId",
|
||||
"bindings[].match.peer",
|
||||
"bindings[].match.peer.kind",
|
||||
"bindings[].match.peer.id",
|
||||
"bindings[].match.guildId",
|
||||
"bindings[].match.teamId",
|
||||
"bindings[].match.roles",
|
||||
"broadcast",
|
||||
"broadcast.strategy",
|
||||
"broadcast.*",
|
||||
"commands",
|
||||
"commands.allowFrom",
|
||||
"hooks",
|
||||
"hooks.enabled",
|
||||
"hooks.path",
|
||||
"hooks.token",
|
||||
"hooks.defaultSessionKey",
|
||||
"hooks.allowRequestSessionKey",
|
||||
"hooks.allowedSessionKeyPrefixes",
|
||||
"hooks.allowedAgentIds",
|
||||
"hooks.maxBodyBytes",
|
||||
"hooks.transformsDir",
|
||||
"hooks.mappings",
|
||||
"hooks.mappings[].action",
|
||||
"hooks.mappings[].wakeMode",
|
||||
"hooks.mappings[].channel",
|
||||
"hooks.mappings[].transform.module",
|
||||
"hooks.gmail",
|
||||
"hooks.gmail.pushToken",
|
||||
"hooks.gmail.tailscale.mode",
|
||||
"hooks.gmail.thinking",
|
||||
"hooks.internal",
|
||||
"hooks.internal.handlers",
|
||||
"hooks.internal.handlers[].event",
|
||||
"hooks.internal.handlers[].module",
|
||||
"hooks.internal.load.extraDirs",
|
||||
"messages",
|
||||
"messages.messagePrefix",
|
||||
"messages.responsePrefix",
|
||||
"messages.groupChat",
|
||||
"messages.groupChat.mentionPatterns",
|
||||
"messages.groupChat.historyLimit",
|
||||
"messages.queue",
|
||||
"messages.queue.mode",
|
||||
"messages.queue.byChannel",
|
||||
"messages.queue.debounceMs",
|
||||
"messages.queue.debounceMsByChannel",
|
||||
"messages.queue.cap",
|
||||
"messages.queue.drop",
|
||||
"messages.inbound",
|
||||
"messages.inbound.byChannel",
|
||||
"messages.removeAckAfterReply",
|
||||
"messages.tts",
|
||||
"channels",
|
||||
"channels.defaults",
|
||||
"channels.defaults.groupPolicy",
|
||||
"channels.defaults.heartbeat",
|
||||
"channels.defaults.heartbeat.showOk",
|
||||
"channels.defaults.heartbeat.showAlerts",
|
||||
"channels.defaults.heartbeat.useIndicator",
|
||||
"gateway",
|
||||
"gateway.mode",
|
||||
"gateway.bind",
|
||||
"gateway.auth.mode",
|
||||
"gateway.tailscale.mode",
|
||||
"gateway.tools.allow",
|
||||
"gateway.tools.deny",
|
||||
"gateway.tls.enabled",
|
||||
"gateway.tls.autoGenerate",
|
||||
"gateway.http",
|
||||
"gateway.http.endpoints",
|
||||
"browser",
|
||||
"browser.enabled",
|
||||
"browser.cdpUrl",
|
||||
"browser.headless",
|
||||
"browser.noSandbox",
|
||||
"browser.profiles",
|
||||
"browser.profiles.*.driver",
|
||||
"tools",
|
||||
"tools.allow",
|
||||
"tools.deny",
|
||||
"tools.exec",
|
||||
"tools.exec.host",
|
||||
"tools.exec.security",
|
||||
"tools.exec.ask",
|
||||
"tools.exec.node",
|
||||
"tools.agentToAgent.enabled",
|
||||
"tools.elevated.enabled",
|
||||
"tools.elevated.allowFrom",
|
||||
"tools.subagents.tools",
|
||||
"tools.sandbox.tools",
|
||||
"web",
|
||||
"web.enabled",
|
||||
"web.heartbeatSeconds",
|
||||
"web.reconnect",
|
||||
"web.reconnect.initialMs",
|
||||
"web.reconnect.maxMs",
|
||||
"web.reconnect.factor",
|
||||
"web.reconnect.jitter",
|
||||
"web.reconnect.maxAttempts",
|
||||
"discovery",
|
||||
"discovery.wideArea.enabled",
|
||||
"discovery.mdns",
|
||||
"discovery.mdns.mode",
|
||||
"canvasHost",
|
||||
"canvasHost.enabled",
|
||||
"canvasHost.root",
|
||||
"canvasHost.port",
|
||||
"canvasHost.liveReload",
|
||||
"talk",
|
||||
"talk.voiceId",
|
||||
"talk.voiceAliases",
|
||||
"talk.modelId",
|
||||
"talk.outputFormat",
|
||||
"talk.interruptOnSpeech",
|
||||
"meta",
|
||||
"env",
|
||||
"env.shellEnv",
|
||||
"env.shellEnv.enabled",
|
||||
"env.shellEnv.timeoutMs",
|
||||
"env.vars",
|
||||
"wizard",
|
||||
"wizard.lastRunAt",
|
||||
"wizard.lastRunVersion",
|
||||
"wizard.lastRunCommit",
|
||||
"wizard.lastRunCommand",
|
||||
"wizard.lastRunMode",
|
||||
"diagnostics",
|
||||
"diagnostics.otel",
|
||||
"diagnostics.cacheTrace",
|
||||
"logging",
|
||||
"logging.level",
|
||||
"logging.file",
|
||||
"logging.consoleLevel",
|
||||
"logging.consoleStyle",
|
||||
"logging.redactSensitive",
|
||||
"logging.redactPatterns",
|
||||
"update",
|
||||
"ui",
|
||||
"ui.assistant",
|
||||
"plugins",
|
||||
"plugins.enabled",
|
||||
"plugins.allow",
|
||||
"plugins.deny",
|
||||
"plugins.load",
|
||||
"plugins.load.paths",
|
||||
"plugins.slots",
|
||||
"plugins.entries",
|
||||
"plugins.entries.*.enabled",
|
||||
"plugins.entries.*.apiKey",
|
||||
"plugins.entries.*.env",
|
||||
"plugins.entries.*.config",
|
||||
"plugins.installs",
|
||||
"auth",
|
||||
"auth.cooldowns",
|
||||
"models",
|
||||
"models.providers",
|
||||
"models.providers.*.baseUrl",
|
||||
"models.providers.*.apiKey",
|
||||
"models.providers.*.api",
|
||||
"models.providers.*.headers",
|
||||
"models.providers.*.models",
|
||||
"models.bedrockDiscovery",
|
||||
"models.bedrockDiscovery.enabled",
|
||||
"models.bedrockDiscovery.region",
|
||||
"models.bedrockDiscovery.providerFilter",
|
||||
"models.bedrockDiscovery.refreshInterval",
|
||||
"models.bedrockDiscovery.defaultContextWindow",
|
||||
"models.bedrockDiscovery.defaultMaxTokens",
|
||||
"agents",
|
||||
"agents.defaults",
|
||||
"agents.list",
|
||||
"agents.defaults.compaction",
|
||||
"agents.defaults.compaction.mode",
|
||||
"agents.defaults.compaction.reserveTokens",
|
||||
"agents.defaults.compaction.keepRecentTokens",
|
||||
"agents.defaults.compaction.reserveTokensFloor",
|
||||
"agents.defaults.compaction.maxHistoryShare",
|
||||
"agents.defaults.compaction.memoryFlush",
|
||||
"agents.defaults.compaction.memoryFlush.enabled",
|
||||
"agents.defaults.compaction.memoryFlush.softThresholdTokens",
|
||||
"agents.defaults.compaction.memoryFlush.prompt",
|
||||
"agents.defaults.compaction.memoryFlush.systemPrompt",
|
||||
] as const;
|
||||
|
||||
const ENUM_EXPECTATIONS: Record<string, string[]> = {
|
||||
"memory.citations": ['"auto"', '"on"', '"off"'],
|
||||
"memory.backend": ['"builtin"', '"qmd"'],
|
||||
"memory.qmd.searchMode": ['"query"', '"search"', '"vsearch"'],
|
||||
"models.mode": ['"merge"', '"replace"'],
|
||||
"models.providers.*.auth": ['"api-key"', '"token"', '"oauth"', '"aws-sdk"'],
|
||||
"gateway.reload.mode": ['"off"', '"restart"', '"hot"', '"hybrid"'],
|
||||
"approvals.exec.mode": ['"session"', '"targets"', '"both"'],
|
||||
"bindings[].match.peer.kind": ['"direct"', '"group"', '"channel"', '"dm"'],
|
||||
"broadcast.strategy": ['"parallel"', '"sequential"'],
|
||||
"hooks.mappings[].action": ['"wake"', '"agent"'],
|
||||
"hooks.mappings[].wakeMode": ['"now"', '"next-heartbeat"'],
|
||||
"hooks.gmail.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
|
||||
"hooks.gmail.thinking": ['"off"', '"minimal"', '"low"', '"medium"', '"high"'],
|
||||
"messages.queue.mode": [
|
||||
'"steer"',
|
||||
'"followup"',
|
||||
'"collect"',
|
||||
'"steer-backlog"',
|
||||
'"steer+backlog"',
|
||||
'"queue"',
|
||||
'"interrupt"',
|
||||
],
|
||||
"messages.queue.drop": ['"old"', '"new"', '"summarize"'],
|
||||
"channels.defaults.groupPolicy": ['"open"', '"disabled"', '"allowlist"'],
|
||||
"gateway.mode": ['"local"', '"remote"'],
|
||||
"gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'],
|
||||
"gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'],
|
||||
"gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
|
||||
"browser.profiles.*.driver": ['"clawd"', '"extension"'],
|
||||
"discovery.mdns.mode": ['"off"', '"minimal"', '"full"'],
|
||||
"wizard.lastRunMode": ['"local"', '"remote"'],
|
||||
"diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'],
|
||||
"logging.level": ['"silent"', '"fatal"', '"error"', '"warn"', '"info"', '"debug"', '"trace"'],
|
||||
"logging.consoleLevel": [
|
||||
'"silent"',
|
||||
'"fatal"',
|
||||
'"error"',
|
||||
'"warn"',
|
||||
'"info"',
|
||||
'"debug"',
|
||||
'"trace"',
|
||||
],
|
||||
"logging.consoleStyle": ['"pretty"', '"compact"', '"json"'],
|
||||
"logging.redactSensitive": ['"off"', '"tools"'],
|
||||
"update.channel": ['"stable"', '"beta"', '"dev"'],
|
||||
"agents.defaults.compaction.mode": ['"default"', '"safeguard"'],
|
||||
};
|
||||
|
||||
const TOOLS_HOOKS_TARGET_KEYS = [
|
||||
"hooks.gmail.account",
|
||||
"hooks.gmail.allowUnsafeExternalContent",
|
||||
"hooks.gmail.hookUrl",
|
||||
"hooks.gmail.includeBody",
|
||||
"hooks.gmail.label",
|
||||
"hooks.gmail.model",
|
||||
"hooks.gmail.serve",
|
||||
"hooks.gmail.subscription",
|
||||
"hooks.gmail.tailscale",
|
||||
"hooks.gmail.topic",
|
||||
"hooks.internal.entries",
|
||||
"hooks.internal.installs",
|
||||
"hooks.internal.load",
|
||||
"hooks.mappings[].allowUnsafeExternalContent",
|
||||
"hooks.mappings[].deliver",
|
||||
"hooks.mappings[].id",
|
||||
"hooks.mappings[].match",
|
||||
"hooks.mappings[].messageTemplate",
|
||||
"hooks.mappings[].model",
|
||||
"hooks.mappings[].name",
|
||||
"hooks.mappings[].textTemplate",
|
||||
"hooks.mappings[].thinking",
|
||||
"hooks.mappings[].transform",
|
||||
"tools.alsoAllow",
|
||||
"tools.byProvider",
|
||||
"tools.exec.approvalRunningNoticeMs",
|
||||
"tools.links.enabled",
|
||||
"tools.links.maxLinks",
|
||||
"tools.links.models",
|
||||
"tools.links.scope",
|
||||
"tools.links.timeoutSeconds",
|
||||
"tools.media.audio.attachments",
|
||||
"tools.media.audio.enabled",
|
||||
"tools.media.audio.language",
|
||||
"tools.media.audio.maxBytes",
|
||||
"tools.media.audio.maxChars",
|
||||
"tools.media.audio.models",
|
||||
"tools.media.audio.prompt",
|
||||
"tools.media.audio.scope",
|
||||
"tools.media.audio.timeoutSeconds",
|
||||
"tools.media.concurrency",
|
||||
"tools.media.image.attachments",
|
||||
"tools.media.image.enabled",
|
||||
"tools.media.image.maxBytes",
|
||||
"tools.media.image.maxChars",
|
||||
"tools.media.image.models",
|
||||
"tools.media.image.prompt",
|
||||
"tools.media.image.scope",
|
||||
"tools.media.image.timeoutSeconds",
|
||||
"tools.media.models",
|
||||
"tools.media.video.attachments",
|
||||
"tools.media.video.enabled",
|
||||
"tools.media.video.maxBytes",
|
||||
"tools.media.video.maxChars",
|
||||
"tools.media.video.models",
|
||||
"tools.media.video.prompt",
|
||||
"tools.media.video.scope",
|
||||
"tools.media.video.timeoutSeconds",
|
||||
"tools.profile",
|
||||
] as const;
|
||||
|
||||
const CHANNELS_AGENTS_TARGET_KEYS = [
|
||||
"agents.defaults.memorySearch.chunking.overlap",
|
||||
"agents.defaults.memorySearch.chunking.tokens",
|
||||
"agents.defaults.memorySearch.enabled",
|
||||
"agents.defaults.memorySearch.model",
|
||||
"agents.defaults.memorySearch.query.maxResults",
|
||||
"agents.defaults.memorySearch.query.minScore",
|
||||
"agents.defaults.memorySearch.sync.onSessionStart",
|
||||
"agents.defaults.memorySearch.sync.watchDebounceMs",
|
||||
"agents.defaults.workspace",
|
||||
"agents.list[].tools.alsoAllow",
|
||||
"agents.list[].tools.byProvider",
|
||||
"agents.list[].tools.profile",
|
||||
"channels.bluebubbles",
|
||||
"channels.discord",
|
||||
"channels.discord.token",
|
||||
"channels.imessage",
|
||||
"channels.imessage.cliPath",
|
||||
"channels.irc",
|
||||
"channels.mattermost",
|
||||
"channels.msteams",
|
||||
"channels.signal",
|
||||
"channels.signal.account",
|
||||
"channels.slack",
|
||||
"channels.slack.appToken",
|
||||
"channels.slack.botToken",
|
||||
"channels.slack.userToken",
|
||||
"channels.slack.userTokenReadOnly",
|
||||
"channels.telegram",
|
||||
"channels.telegram.botToken",
|
||||
"channels.telegram.capabilities.inlineButtons",
|
||||
"channels.whatsapp",
|
||||
] as const;
|
||||
|
||||
const FINAL_BACKLOG_TARGET_KEYS = [
|
||||
"browser.evaluateEnabled",
|
||||
"browser.remoteCdpHandshakeTimeoutMs",
|
||||
"browser.remoteCdpTimeoutMs",
|
||||
"browser.snapshotDefaults",
|
||||
"browser.snapshotDefaults.mode",
|
||||
"browser.ssrfPolicy",
|
||||
"browser.ssrfPolicy.allowPrivateNetwork",
|
||||
"browser.ssrfPolicy.allowedHostnames",
|
||||
"browser.ssrfPolicy.hostnameAllowlist",
|
||||
"diagnostics.enabled",
|
||||
"diagnostics.otel.enabled",
|
||||
"diagnostics.otel.endpoint",
|
||||
"diagnostics.otel.flushIntervalMs",
|
||||
"diagnostics.otel.headers",
|
||||
"diagnostics.otel.logs",
|
||||
"diagnostics.otel.metrics",
|
||||
"diagnostics.otel.sampleRate",
|
||||
"diagnostics.otel.serviceName",
|
||||
"diagnostics.otel.traces",
|
||||
"gateway.remote.password",
|
||||
"gateway.remote.token",
|
||||
"skills.load.watch",
|
||||
"skills.load.watchDebounceMs",
|
||||
"talk.apiKey",
|
||||
"ui.assistant.avatar",
|
||||
"ui.assistant.name",
|
||||
"ui.seamColor",
|
||||
] as const;
|
||||
|
||||
describe("config help copy quality", () => {
|
||||
it("keeps root section labels and help complete", () => {
|
||||
for (const key of ROOT_SECTIONS) {
|
||||
expect(FIELD_LABELS[key], `missing root label for ${key}`).toBeDefined();
|
||||
expect(FIELD_HELP[key], `missing root help for ${key}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps labels in parity for all help keys", () => {
|
||||
for (const key of Object.keys(FIELD_HELP)) {
|
||||
expect(FIELD_LABELS[key], `missing label for help key ${key}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("covers the target confusing fields with non-trivial explanations", () => {
|
||||
for (const key of TARGET_KEYS) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
|
||||
expect(
|
||||
/(default|keep|use|enable|disable|controls|selects|sets|defines)/i.test(help),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers tools/hooks help keys with non-trivial operational guidance", () => {
|
||||
for (const key of TOOLS_HOOKS_TARGET_KEYS) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
|
||||
expect(
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
|
||||
help,
|
||||
),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers channels/agents help keys with non-trivial operational guidance", () => {
|
||||
for (const key of CHANNELS_AGENTS_TARGET_KEYS) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
|
||||
expect(
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
|
||||
help,
|
||||
),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers final backlog help keys with non-trivial operational guidance", () => {
|
||||
for (const key of FINAL_BACKLOG_TARGET_KEYS) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
|
||||
expect(
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
|
||||
help,
|
||||
),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("documents option behavior for enum-style fields", () => {
|
||||
for (const [key, options] of Object.entries(ENUM_EXPECTATIONS)) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for enum key ${key}`).toBeDefined();
|
||||
for (const token of options) {
|
||||
expect(help.includes(token), `missing option ${token} in ${key}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("explains memory citations mode semantics", () => {
|
||||
const help = FIELD_HELP["memory.citations"];
|
||||
expect(help.includes('"auto"')).toBe(true);
|
||||
expect(help.includes('"on"')).toBe(true);
|
||||
expect(help.includes('"off"')).toBe(true);
|
||||
expect(/always|always shows/i.test(help)).toBe(true);
|
||||
expect(/hides|hide/i.test(help)).toBe(true);
|
||||
});
|
||||
|
||||
it("includes concrete examples on path and interval fields", () => {
|
||||
expect(FIELD_HELP["memory.qmd.paths.pattern"].includes("**/*.md")).toBe(true);
|
||||
expect(FIELD_HELP["memory.qmd.update.interval"].includes("5m")).toBe(true);
|
||||
expect(FIELD_HELP["memory.qmd.update.embedInterval"].includes("60m")).toBe(true);
|
||||
expect(FIELD_HELP["agents.defaults.memorySearch.store.path"]).toContain(
|
||||
"~/.openclaw/memory/{agentId}.sqlite",
|
||||
);
|
||||
});
|
||||
|
||||
it("documents cron deprecation, migration, and retention formats", () => {
|
||||
const legacy = FIELD_HELP["cron.webhook"];
|
||||
expect(/deprecated|legacy/i.test(legacy)).toBe(true);
|
||||
expect(legacy.includes('delivery.mode="webhook"')).toBe(true);
|
||||
expect(legacy.includes("delivery.to")).toBe(true);
|
||||
|
||||
const retention = FIELD_HELP["cron.sessionRetention"];
|
||||
expect(retention.includes("24h")).toBe(true);
|
||||
expect(retention.includes("7d")).toBe(true);
|
||||
expect(retention.includes("1h30m")).toBe(true);
|
||||
expect(/false/i.test(retention)).toBe(true);
|
||||
|
||||
const token = FIELD_HELP["cron.webhookToken"];
|
||||
expect(/token|bearer/i.test(token)).toBe(true);
|
||||
expect(/secret|env|rotate/i.test(token)).toBe(true);
|
||||
});
|
||||
|
||||
it("documents session send-policy examples and prefix semantics", () => {
|
||||
const rules = FIELD_HELP["session.sendPolicy.rules"];
|
||||
expect(rules.includes("{ action:")).toBe(true);
|
||||
expect(rules.includes('"deny"')).toBe(true);
|
||||
expect(rules.includes('"discord"')).toBe(true);
|
||||
|
||||
const keyPrefix = FIELD_HELP["session.sendPolicy.rules[].match.keyPrefix"];
|
||||
expect(/normalized/i.test(keyPrefix)).toBe(true);
|
||||
|
||||
const rawKeyPrefix = FIELD_HELP["session.sendPolicy.rules[].match.rawKeyPrefix"];
|
||||
expect(/raw|unnormalized/i.test(rawKeyPrefix)).toBe(true);
|
||||
});
|
||||
|
||||
it("documents session maintenance duration/size examples and deprecations", () => {
|
||||
const pruneAfter = FIELD_HELP["session.maintenance.pruneAfter"];
|
||||
expect(pruneAfter.includes("30d")).toBe(true);
|
||||
expect(pruneAfter.includes("12h")).toBe(true);
|
||||
|
||||
const rotate = FIELD_HELP["session.maintenance.rotateBytes"];
|
||||
expect(rotate.includes("10mb")).toBe(true);
|
||||
expect(rotate.includes("1gb")).toBe(true);
|
||||
|
||||
const deprecated = FIELD_HELP["session.maintenance.pruneDays"];
|
||||
expect(/deprecated/i.test(deprecated)).toBe(true);
|
||||
expect(deprecated.includes("session.maintenance.pruneAfter")).toBe(true);
|
||||
});
|
||||
|
||||
it("documents approvals filters and target semantics", () => {
|
||||
const sessionFilter = FIELD_HELP["approvals.exec.sessionFilter"];
|
||||
expect(/substring|regex/i.test(sessionFilter)).toBe(true);
|
||||
expect(sessionFilter.includes("discord:")).toBe(true);
|
||||
expect(sessionFilter.includes("^agent:ops:")).toBe(true);
|
||||
|
||||
const agentFilter = FIELD_HELP["approvals.exec.agentFilter"];
|
||||
expect(agentFilter.includes("primary")).toBe(true);
|
||||
expect(agentFilter.includes("ops-agent")).toBe(true);
|
||||
|
||||
const targetTo = FIELD_HELP["approvals.exec.targets[].to"];
|
||||
expect(/channel ID|user ID|thread root/i.test(targetTo)).toBe(true);
|
||||
expect(/differs|per provider/i.test(targetTo)).toBe(true);
|
||||
});
|
||||
|
||||
it("documents broadcast and audio command examples", () => {
|
||||
const audioCmd = FIELD_HELP["audio.transcription.command"];
|
||||
expect(audioCmd.includes("whisper-cli")).toBe(true);
|
||||
expect(audioCmd.includes("{input}")).toBe(true);
|
||||
|
||||
const broadcastMap = FIELD_HELP["broadcast.*"];
|
||||
expect(/source peer ID/i.test(broadcastMap)).toBe(true);
|
||||
expect(/destination peer IDs/i.test(broadcastMap)).toBe(true);
|
||||
});
|
||||
|
||||
it("documents hook transform safety and queue behavior options", () => {
|
||||
const transformModule = FIELD_HELP["hooks.mappings[].transform.module"];
|
||||
expect(/relative/i.test(transformModule)).toBe(true);
|
||||
expect(/path traversal|reviewed|controlled/i.test(transformModule)).toBe(true);
|
||||
|
||||
const queueMode = FIELD_HELP["messages.queue.mode"];
|
||||
expect(queueMode.includes('"interrupt"')).toBe(true);
|
||||
expect(queueMode.includes('"steer+backlog"')).toBe(true);
|
||||
});
|
||||
|
||||
it("documents gateway bind modes and web reconnect semantics", () => {
|
||||
const bind = FIELD_HELP["gateway.bind"];
|
||||
expect(bind.includes('"loopback"')).toBe(true);
|
||||
expect(bind.includes('"tailnet"')).toBe(true);
|
||||
|
||||
const reconnect = FIELD_HELP["web.reconnect.maxAttempts"];
|
||||
expect(/0 means no retries|no retries/i.test(reconnect)).toBe(true);
|
||||
expect(/failure sequence|retry/i.test(reconnect)).toBe(true);
|
||||
});
|
||||
|
||||
it("documents metadata/admin semantics for logging, wizard, and plugins", () => {
|
||||
const wizardMode = FIELD_HELP["wizard.lastRunMode"];
|
||||
expect(wizardMode.includes('"local"')).toBe(true);
|
||||
expect(wizardMode.includes('"remote"')).toBe(true);
|
||||
|
||||
const consoleStyle = FIELD_HELP["logging.consoleStyle"];
|
||||
expect(consoleStyle.includes('"pretty"')).toBe(true);
|
||||
expect(consoleStyle.includes('"compact"')).toBe(true);
|
||||
expect(consoleStyle.includes('"json"')).toBe(true);
|
||||
|
||||
const pluginApiKey = FIELD_HELP["plugins.entries.*.apiKey"];
|
||||
expect(/secret|env|credential/i.test(pluginApiKey)).toBe(true);
|
||||
|
||||
const pluginEnv = FIELD_HELP["plugins.entries.*.env"];
|
||||
expect(/scope|plugin|environment/i.test(pluginEnv)).toBe(true);
|
||||
});
|
||||
|
||||
it("documents auth/model root semantics and provider secret handling", () => {
|
||||
const providerKey = FIELD_HELP["models.providers.*.apiKey"];
|
||||
expect(/secret|env|credential/i.test(providerKey)).toBe(true);
|
||||
|
||||
const bedrockRefresh = FIELD_HELP["models.bedrockDiscovery.refreshInterval"];
|
||||
expect(/refresh|seconds|interval/i.test(bedrockRefresh)).toBe(true);
|
||||
expect(/cost|noise|api/i.test(bedrockRefresh)).toBe(true);
|
||||
|
||||
const authCooldowns = FIELD_HELP["auth.cooldowns"];
|
||||
expect(/cooldown|backoff|retry/i.test(authCooldowns)).toBe(true);
|
||||
});
|
||||
|
||||
it("documents agent compaction safeguards and memory flush behavior", () => {
|
||||
const mode = FIELD_HELP["agents.defaults.compaction.mode"];
|
||||
expect(mode.includes('"default"')).toBe(true);
|
||||
expect(mode.includes('"safeguard"')).toBe(true);
|
||||
|
||||
const historyShare = FIELD_HELP["agents.defaults.compaction.maxHistoryShare"];
|
||||
expect(/0\\.1-0\\.9|fraction|share/i.test(historyShare)).toBe(true);
|
||||
|
||||
const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"];
|
||||
expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { FIELD_HELP } from "./schema.help.js";
|
||||
import { FIELD_LABELS } from "./schema.labels.js";
|
||||
import { applyDerivedTags } from "./schema.tags.js";
|
||||
import { sensitive } from "./zod-schema.sensitive.js";
|
||||
|
||||
const log = createSubsystemLogger("config/schema");
|
||||
@@ -9,6 +10,7 @@ const log = createSubsystemLogger("config/schema");
|
||||
export type ConfigUiHint = {
|
||||
label?: string;
|
||||
help?: string;
|
||||
tags?: string[];
|
||||
group?: string;
|
||||
order?: number;
|
||||
advanced?: boolean;
|
||||
@@ -143,7 +145,7 @@ export function buildBaseHints(): ConfigUiHints {
|
||||
const current = hints[path];
|
||||
hints[path] = current ? { ...current, placeholder } : { placeholder };
|
||||
}
|
||||
return hints;
|
||||
return applyDerivedTags(hints);
|
||||
}
|
||||
|
||||
export function applySensitiveHints(
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import { IRC_FIELD_LABELS } from "./schema.irc.js";
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
meta: "Metadata",
|
||||
"meta.lastTouchedVersion": "Config Last Touched Version",
|
||||
"meta.lastTouchedAt": "Config Last Touched At",
|
||||
env: "Environment",
|
||||
"env.shellEnv": "Shell Environment Import",
|
||||
"env.shellEnv.enabled": "Shell Environment Import Enabled",
|
||||
"env.shellEnv.timeoutMs": "Shell Environment Import Timeout (ms)",
|
||||
"env.vars": "Environment Variable Overrides",
|
||||
wizard: "Setup Wizard State",
|
||||
"wizard.lastRunAt": "Wizard Last Run Timestamp",
|
||||
"wizard.lastRunVersion": "Wizard Last Run Version",
|
||||
"wizard.lastRunCommit": "Wizard Last Run Commit",
|
||||
"wizard.lastRunCommand": "Wizard Last Run Command",
|
||||
"wizard.lastRunMode": "Wizard Last Run Mode",
|
||||
diagnostics: "Diagnostics",
|
||||
"diagnostics.otel": "OpenTelemetry",
|
||||
"diagnostics.cacheTrace": "Cache Trace",
|
||||
logging: "Logging",
|
||||
"logging.level": "Log Level",
|
||||
"logging.file": "Log File Path",
|
||||
"logging.consoleLevel": "Console Log Level",
|
||||
"logging.consoleStyle": "Console Log Style",
|
||||
"logging.redactSensitive": "Sensitive Data Redaction Mode",
|
||||
"logging.redactPatterns": "Custom Redaction Patterns",
|
||||
update: "Updates",
|
||||
"update.channel": "Update Channel",
|
||||
"update.checkOnStart": "Update Check on Start",
|
||||
"update.auto.enabled": "Auto Update Enabled",
|
||||
@@ -28,6 +51,41 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
||||
"agents.list.*.identity.avatar": "Identity Avatar",
|
||||
"agents.list.*.skills": "Agent Skill Filter",
|
||||
agents: "Agents",
|
||||
"agents.defaults": "Agent Defaults",
|
||||
"agents.list": "Agent List",
|
||||
gateway: "Gateway",
|
||||
"gateway.port": "Gateway Port",
|
||||
"gateway.mode": "Gateway Mode",
|
||||
"gateway.bind": "Gateway Bind Mode",
|
||||
"gateway.customBindHost": "Gateway Custom Bind Host",
|
||||
"gateway.controlUi": "Control UI",
|
||||
"gateway.controlUi.enabled": "Control UI Enabled",
|
||||
"gateway.auth": "Gateway Auth",
|
||||
"gateway.auth.mode": "Gateway Auth Mode",
|
||||
"gateway.auth.allowTailscale": "Gateway Auth Allow Tailscale Identity",
|
||||
"gateway.auth.rateLimit": "Gateway Auth Rate Limit",
|
||||
"gateway.auth.trustedProxy": "Gateway Trusted Proxy Auth",
|
||||
"gateway.trustedProxies": "Gateway Trusted Proxy CIDRs",
|
||||
"gateway.allowRealIpFallback": "Gateway Allow x-real-ip Fallback",
|
||||
"gateway.tools": "Gateway Tool Exposure Policy",
|
||||
"gateway.tools.allow": "Gateway Tool Allowlist",
|
||||
"gateway.tools.deny": "Gateway Tool Denylist",
|
||||
"gateway.channelHealthCheckMinutes": "Gateway Channel Health Check Interval (min)",
|
||||
"gateway.tailscale": "Gateway Tailscale",
|
||||
"gateway.tailscale.mode": "Gateway Tailscale Mode",
|
||||
"gateway.tailscale.resetOnExit": "Gateway Tailscale Reset on Exit",
|
||||
"gateway.remote": "Remote Gateway",
|
||||
"gateway.remote.transport": "Remote Gateway Transport",
|
||||
"gateway.reload": "Config Reload",
|
||||
"gateway.tls": "Gateway TLS",
|
||||
"gateway.tls.enabled": "Gateway TLS Enabled",
|
||||
"gateway.tls.autoGenerate": "Gateway TLS Auto-Generate Cert",
|
||||
"gateway.tls.certPath": "Gateway TLS Certificate Path",
|
||||
"gateway.tls.keyPath": "Gateway TLS Key Path",
|
||||
"gateway.tls.caPath": "Gateway TLS CA Path",
|
||||
"gateway.http": "Gateway HTTP API",
|
||||
"gateway.http.endpoints": "Gateway HTTP Endpoints",
|
||||
"gateway.remote.url": "Remote Gateway URL",
|
||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
||||
@@ -36,6 +94,25 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint",
|
||||
"gateway.auth.token": "Gateway Token",
|
||||
"gateway.auth.password": "Gateway Password",
|
||||
browser: "Browser",
|
||||
"browser.enabled": "Browser Enabled",
|
||||
"browser.cdpUrl": "Browser CDP URL",
|
||||
"browser.color": "Browser Accent Color",
|
||||
"browser.executablePath": "Browser Executable Path",
|
||||
"browser.headless": "Browser Headless Mode",
|
||||
"browser.noSandbox": "Browser No-Sandbox Mode",
|
||||
"browser.attachOnly": "Browser Attach-only Mode",
|
||||
"browser.defaultProfile": "Browser Default Profile",
|
||||
"browser.profiles": "Browser Profiles",
|
||||
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
|
||||
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",
|
||||
"browser.profiles.*.driver": "Browser Profile Driver",
|
||||
"browser.profiles.*.color": "Browser Profile Accent Color",
|
||||
tools: "Tools",
|
||||
"tools.allow": "Tool Allowlist",
|
||||
"tools.deny": "Tool Denylist",
|
||||
"tools.web": "Web Tools",
|
||||
"tools.exec": "Exec Tool",
|
||||
"tools.media.image.enabled": "Enable Image Understanding",
|
||||
"tools.media.image.maxBytes": "Image Understanding Max Bytes",
|
||||
"tools.media.image.maxChars": "Image Understanding Max Chars",
|
||||
@@ -94,9 +171,30 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.exec.security": "Exec Security",
|
||||
"tools.exec.ask": "Exec Ask",
|
||||
"tools.exec.node": "Exec Node Binding",
|
||||
"tools.agentToAgent": "Agent-to-Agent Tool Access",
|
||||
"tools.agentToAgent.enabled": "Enable Agent-to-Agent Tool",
|
||||
"tools.agentToAgent.allow": "Agent-to-Agent Target Allowlist",
|
||||
"tools.elevated": "Elevated Tool Access",
|
||||
"tools.elevated.enabled": "Enable Elevated Tool Access",
|
||||
"tools.elevated.allowFrom": "Elevated Tool Allow Rules",
|
||||
"tools.subagents": "Subagent Tool Policy",
|
||||
"tools.subagents.tools": "Subagent Tool Allow/Deny Policy",
|
||||
"tools.sandbox": "Sandbox Tool Policy",
|
||||
"tools.sandbox.tools": "Sandbox Tool Allow/Deny Policy",
|
||||
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||
"tools.exec.safeBins": "Exec Safe Bins",
|
||||
"tools.exec.safeBinProfiles": "Exec Safe Bin Profiles",
|
||||
approvals: "Approvals",
|
||||
"approvals.exec": "Exec Approval Forwarding",
|
||||
"approvals.exec.enabled": "Forward Exec Approvals",
|
||||
"approvals.exec.mode": "Approval Forwarding Mode",
|
||||
"approvals.exec.agentFilter": "Approval Agent Filter",
|
||||
"approvals.exec.sessionFilter": "Approval Session Filter",
|
||||
"approvals.exec.targets": "Approval Forwarding Targets",
|
||||
"approvals.exec.targets[].channel": "Approval Target Channel",
|
||||
"approvals.exec.targets[].to": "Approval Target Destination",
|
||||
"approvals.exec.targets[].accountId": "Approval Target Account ID",
|
||||
"approvals.exec.targets[].threadId": "Approval Target Thread ID",
|
||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||
@@ -110,12 +208,23 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.search.maxResults": "Web Search Max Results",
|
||||
"tools.web.search.timeoutSeconds": "Web Search Timeout (sec)",
|
||||
"tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)",
|
||||
"tools.web.search.perplexity.apiKey": "Perplexity API Key",
|
||||
"tools.web.search.perplexity.baseUrl": "Perplexity Base URL",
|
||||
"tools.web.search.perplexity.model": "Perplexity Model",
|
||||
"tools.web.fetch.enabled": "Enable Web Fetch Tool",
|
||||
"tools.web.fetch.maxChars": "Web Fetch Max Chars",
|
||||
"tools.web.fetch.maxCharsCap": "Web Fetch Hard Max Chars",
|
||||
"tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)",
|
||||
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
|
||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||
"tools.web.fetch.readability": "Web Fetch Readability Extraction",
|
||||
"tools.web.fetch.firecrawl.enabled": "Enable Firecrawl Fallback",
|
||||
"tools.web.fetch.firecrawl.apiKey": "Firecrawl API Key",
|
||||
"tools.web.fetch.firecrawl.baseUrl": "Firecrawl Base URL",
|
||||
"tools.web.fetch.firecrawl.onlyMainContent": "Firecrawl Main Content Only",
|
||||
"tools.web.fetch.firecrawl.maxAgeMs": "Firecrawl Cache Max Age (ms)",
|
||||
"tools.web.fetch.firecrawl.timeoutSeconds": "Firecrawl Timeout (sec)",
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"gateway.controlUi.root": "Control UI Assets Root",
|
||||
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",
|
||||
@@ -128,8 +237,30 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.nodes.browser.node": "Gateway Node Browser Pin",
|
||||
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
|
||||
"gateway.nodes.denyCommands": "Gateway Node Denylist",
|
||||
nodeHost: "Node Host",
|
||||
"nodeHost.browserProxy": "Node Browser Proxy",
|
||||
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
|
||||
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
|
||||
media: "Media",
|
||||
"media.preserveFilenames": "Preserve Media Filenames",
|
||||
audio: "Audio",
|
||||
"audio.transcription": "Audio Transcription",
|
||||
"audio.transcription.command": "Audio Transcription Command",
|
||||
"audio.transcription.timeoutSeconds": "Audio Transcription Timeout (sec)",
|
||||
bindings: "Bindings",
|
||||
"bindings[].agentId": "Binding Agent ID",
|
||||
"bindings[].match": "Binding Match Rule",
|
||||
"bindings[].match.channel": "Binding Channel",
|
||||
"bindings[].match.accountId": "Binding Account ID",
|
||||
"bindings[].match.peer": "Binding Peer Match",
|
||||
"bindings[].match.peer.kind": "Binding Peer Kind",
|
||||
"bindings[].match.peer.id": "Binding Peer ID",
|
||||
"bindings[].match.guildId": "Binding Guild ID",
|
||||
"bindings[].match.teamId": "Binding Team ID",
|
||||
"bindings[].match.roles": "Binding Roles",
|
||||
broadcast: "Broadcast",
|
||||
"broadcast.strategy": "Broadcast Strategy",
|
||||
"broadcast.*": "Broadcast Destination List",
|
||||
"skills.load.watch": "Watch Skills",
|
||||
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
@@ -149,7 +280,11 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL",
|
||||
"agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key",
|
||||
"agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers",
|
||||
"agents.defaults.memorySearch.remote.batch.enabled": "Remote Batch Embedding Enabled",
|
||||
"agents.defaults.memorySearch.remote.batch.wait": "Remote Batch Wait for Completion",
|
||||
"agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency",
|
||||
"agents.defaults.memorySearch.remote.batch.pollIntervalMs": "Remote Batch Poll Interval (ms)",
|
||||
"agents.defaults.memorySearch.remote.batch.timeoutMinutes": "Remote Batch Timeout (min)",
|
||||
"agents.defaults.memorySearch.model": "Memory Search Model",
|
||||
"agents.defaults.memorySearch.fallback": "Memory Search Fallback",
|
||||
"agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path",
|
||||
@@ -182,6 +317,11 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"memory.backend": "Memory Backend",
|
||||
"memory.citations": "Memory Citations Mode",
|
||||
"memory.qmd.command": "QMD Binary",
|
||||
"memory.qmd.mcporter": "QMD MCPorter",
|
||||
"memory.qmd.mcporter.enabled": "QMD MCPorter Enabled",
|
||||
"memory.qmd.mcporter.serverName": "QMD MCPorter Server Name",
|
||||
"memory.qmd.mcporter.startDaemon": "QMD MCPorter Start Daemon",
|
||||
"memory.qmd.searchMode": "QMD Search Mode",
|
||||
"memory.qmd.includeDefaultMemory": "QMD Include Default Memory",
|
||||
"memory.qmd.paths": "QMD Extra Paths",
|
||||
"memory.qmd.paths.path": "QMD Path",
|
||||
@@ -203,8 +343,27 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars",
|
||||
"memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)",
|
||||
"memory.qmd.scope": "QMD Surface Scope",
|
||||
auth: "Auth",
|
||||
"auth.profiles": "Auth Profiles",
|
||||
"auth.order": "Auth Profile Order",
|
||||
"auth.cooldowns": "Auth Cooldowns",
|
||||
models: "Models",
|
||||
"models.mode": "Model Catalog Mode",
|
||||
"models.providers": "Model Providers",
|
||||
"models.providers.*.baseUrl": "Model Provider Base URL",
|
||||
"models.providers.*.apiKey": "Model Provider API Key",
|
||||
"models.providers.*.auth": "Model Provider Auth Mode",
|
||||
"models.providers.*.api": "Model Provider API Adapter",
|
||||
"models.providers.*.headers": "Model Provider Headers",
|
||||
"models.providers.*.authHeader": "Model Provider Authorization Header",
|
||||
"models.providers.*.models": "Model Provider Model List",
|
||||
"models.bedrockDiscovery": "Bedrock Model Discovery",
|
||||
"models.bedrockDiscovery.enabled": "Bedrock Discovery Enabled",
|
||||
"models.bedrockDiscovery.region": "Bedrock Discovery Region",
|
||||
"models.bedrockDiscovery.providerFilter": "Bedrock Discovery Provider Filter",
|
||||
"models.bedrockDiscovery.refreshInterval": "Bedrock Discovery Refresh Interval (s)",
|
||||
"models.bedrockDiscovery.defaultContextWindow": "Bedrock Default Context Window",
|
||||
"models.bedrockDiscovery.defaultMaxTokens": "Bedrock Default Max Tokens",
|
||||
"auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)",
|
||||
"auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides",
|
||||
"auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)",
|
||||
@@ -219,6 +378,22 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.defaults.humanDelay.minMs": "Human Delay Min (ms)",
|
||||
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
|
||||
"agents.defaults.cliBackends": "CLI Backends",
|
||||
"agents.defaults.compaction": "Compaction",
|
||||
"agents.defaults.compaction.mode": "Compaction Mode",
|
||||
"agents.defaults.compaction.reserveTokens": "Compaction Reserve Tokens",
|
||||
"agents.defaults.compaction.keepRecentTokens": "Compaction Keep Recent Tokens",
|
||||
"agents.defaults.compaction.reserveTokensFloor": "Compaction Reserve Token Floor",
|
||||
"agents.defaults.compaction.maxHistoryShare": "Compaction Max History Share",
|
||||
"agents.defaults.compaction.memoryFlush": "Compaction Memory Flush",
|
||||
"agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled",
|
||||
"agents.defaults.compaction.memoryFlush.softThresholdTokens":
|
||||
"Compaction Memory Flush Soft Threshold",
|
||||
"agents.defaults.compaction.memoryFlush.prompt": "Compaction Memory Flush Prompt",
|
||||
"agents.defaults.compaction.memoryFlush.systemPrompt": "Compaction Memory Flush System Prompt",
|
||||
"agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings",
|
||||
"agents.defaults.sandbox.browser.network": "Sandbox Browser Network",
|
||||
"agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range",
|
||||
commands: "Commands",
|
||||
"commands.native": "Native Commands",
|
||||
"commands.nativeSkills": "Native Skill Commands",
|
||||
"commands.text": "Text Commands",
|
||||
@@ -231,7 +406,10 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"commands.ownerAllowFrom": "Command Owners",
|
||||
"commands.ownerDisplay": "Owner ID Display",
|
||||
"commands.ownerDisplaySecret": "Owner ID Hash Secret",
|
||||
"commands.allowFrom": "Command Elevated Access Rules",
|
||||
ui: "UI",
|
||||
"ui.seamColor": "Accent Color",
|
||||
"ui.assistant": "Assistant Appearance",
|
||||
"ui.assistant.name": "Assistant Name",
|
||||
"ui.assistant.avatar": "Assistant Avatar",
|
||||
"browser.evaluateEnabled": "Browser Evaluate Enabled",
|
||||
@@ -243,19 +421,174 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"browser.ssrfPolicy.hostnameAllowlist": "Browser Hostname Allowlist",
|
||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
|
||||
session: "Session",
|
||||
"session.scope": "Session Scope",
|
||||
"session.dmScope": "DM Session Scope",
|
||||
"session.identityLinks": "Session Identity Links",
|
||||
"session.resetTriggers": "Session Reset Triggers",
|
||||
"session.idleMinutes": "Session Idle Minutes",
|
||||
"session.reset": "Session Reset Policy",
|
||||
"session.reset.mode": "Session Reset Mode",
|
||||
"session.reset.atHour": "Session Daily Reset Hour",
|
||||
"session.reset.idleMinutes": "Session Reset Idle Minutes",
|
||||
"session.resetByType": "Session Reset by Chat Type",
|
||||
"session.resetByType.direct": "Session Reset (Direct)",
|
||||
"session.resetByType.dm": "Session Reset (DM Deprecated Alias)",
|
||||
"session.resetByType.group": "Session Reset (Group)",
|
||||
"session.resetByType.thread": "Session Reset (Thread)",
|
||||
"session.resetByChannel": "Session Reset by Channel",
|
||||
"session.store": "Session Store Path",
|
||||
"session.typingIntervalSeconds": "Session Typing Interval (seconds)",
|
||||
"session.typingMode": "Session Typing Mode",
|
||||
"session.mainKey": "Session Main Key",
|
||||
"session.sendPolicy": "Session Send Policy",
|
||||
"session.sendPolicy.default": "Session Send Policy Default Action",
|
||||
"session.sendPolicy.rules": "Session Send Policy Rules",
|
||||
"session.sendPolicy.rules[].action": "Session Send Rule Action",
|
||||
"session.sendPolicy.rules[].match": "Session Send Rule Match",
|
||||
"session.sendPolicy.rules[].match.channel": "Session Send Rule Channel",
|
||||
"session.sendPolicy.rules[].match.chatType": "Session Send Rule Chat Type",
|
||||
"session.sendPolicy.rules[].match.keyPrefix": "Session Send Rule Key Prefix",
|
||||
"session.sendPolicy.rules[].match.rawKeyPrefix": "Session Send Rule Raw Key Prefix",
|
||||
"session.agentToAgent": "Session Agent-to-Agent",
|
||||
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
||||
"session.threadBindings": "Session Thread Bindings",
|
||||
"session.threadBindings.enabled": "Thread Binding Enabled",
|
||||
"session.threadBindings.ttlHours": "Thread Binding TTL (hours)",
|
||||
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
||||
"session.maintenance": "Session Maintenance",
|
||||
"session.maintenance.mode": "Session Maintenance Mode",
|
||||
"session.maintenance.pruneAfter": "Session Prune After",
|
||||
"session.maintenance.pruneDays": "Session Prune Days (Deprecated)",
|
||||
"session.maintenance.maxEntries": "Session Max Entries",
|
||||
"session.maintenance.rotateBytes": "Session Rotate Size",
|
||||
cron: "Cron",
|
||||
"cron.enabled": "Cron Enabled",
|
||||
"cron.store": "Cron Store Path",
|
||||
"cron.maxConcurrentRuns": "Cron Max Concurrent Runs",
|
||||
"cron.webhook": "Cron Legacy Webhook (Deprecated)",
|
||||
"cron.webhookToken": "Cron Webhook Bearer Token",
|
||||
"cron.sessionRetention": "Cron Session Retention",
|
||||
hooks: "Hooks",
|
||||
"hooks.enabled": "Hooks Enabled",
|
||||
"hooks.path": "Hooks Endpoint Path",
|
||||
"hooks.token": "Hooks Auth Token",
|
||||
"hooks.defaultSessionKey": "Hooks Default Session Key",
|
||||
"hooks.allowRequestSessionKey": "Hooks Allow Request Session Key",
|
||||
"hooks.allowedSessionKeyPrefixes": "Hooks Allowed Session Key Prefixes",
|
||||
"hooks.allowedAgentIds": "Hooks Allowed Agent IDs",
|
||||
"hooks.maxBodyBytes": "Hooks Max Body Bytes",
|
||||
"hooks.presets": "Hooks Presets",
|
||||
"hooks.transformsDir": "Hooks Transforms Directory",
|
||||
"hooks.mappings": "Hook Mappings",
|
||||
"hooks.mappings[].id": "Hook Mapping ID",
|
||||
"hooks.mappings[].match": "Hook Mapping Match",
|
||||
"hooks.mappings[].match.path": "Hook Mapping Match Path",
|
||||
"hooks.mappings[].match.source": "Hook Mapping Match Source",
|
||||
"hooks.mappings[].action": "Hook Mapping Action",
|
||||
"hooks.mappings[].wakeMode": "Hook Mapping Wake Mode",
|
||||
"hooks.mappings[].name": "Hook Mapping Name",
|
||||
"hooks.mappings[].agentId": "Hook Mapping Agent ID",
|
||||
"hooks.mappings[].sessionKey": "Hook Mapping Session Key",
|
||||
"hooks.mappings[].messageTemplate": "Hook Mapping Message Template",
|
||||
"hooks.mappings[].textTemplate": "Hook Mapping Text Template",
|
||||
"hooks.mappings[].deliver": "Hook Mapping Deliver Reply",
|
||||
"hooks.mappings[].allowUnsafeExternalContent": "Hook Mapping Allow Unsafe External Content",
|
||||
"hooks.mappings[].channel": "Hook Mapping Delivery Channel",
|
||||
"hooks.mappings[].to": "Hook Mapping Delivery Destination",
|
||||
"hooks.mappings[].model": "Hook Mapping Model Override",
|
||||
"hooks.mappings[].thinking": "Hook Mapping Thinking Override",
|
||||
"hooks.mappings[].timeoutSeconds": "Hook Mapping Timeout (sec)",
|
||||
"hooks.mappings[].transform": "Hook Mapping Transform",
|
||||
"hooks.mappings[].transform.module": "Hook Transform Module",
|
||||
"hooks.mappings[].transform.export": "Hook Transform Export",
|
||||
"hooks.gmail": "Gmail Hook",
|
||||
"hooks.gmail.account": "Gmail Hook Account",
|
||||
"hooks.gmail.label": "Gmail Hook Label",
|
||||
"hooks.gmail.topic": "Gmail Hook Pub/Sub Topic",
|
||||
"hooks.gmail.subscription": "Gmail Hook Subscription",
|
||||
"hooks.gmail.pushToken": "Gmail Hook Push Token",
|
||||
"hooks.gmail.hookUrl": "Gmail Hook Callback URL",
|
||||
"hooks.gmail.includeBody": "Gmail Hook Include Body",
|
||||
"hooks.gmail.maxBytes": "Gmail Hook Max Body Bytes",
|
||||
"hooks.gmail.renewEveryMinutes": "Gmail Hook Renew Interval (min)",
|
||||
"hooks.gmail.allowUnsafeExternalContent": "Gmail Hook Allow Unsafe External Content",
|
||||
"hooks.gmail.serve": "Gmail Hook Local Server",
|
||||
"hooks.gmail.serve.bind": "Gmail Hook Server Bind Address",
|
||||
"hooks.gmail.serve.port": "Gmail Hook Server Port",
|
||||
"hooks.gmail.serve.path": "Gmail Hook Server Path",
|
||||
"hooks.gmail.tailscale": "Gmail Hook Tailscale",
|
||||
"hooks.gmail.tailscale.mode": "Gmail Hook Tailscale Mode",
|
||||
"hooks.gmail.tailscale.path": "Gmail Hook Tailscale Path",
|
||||
"hooks.gmail.tailscale.target": "Gmail Hook Tailscale Target",
|
||||
"hooks.gmail.model": "Gmail Hook Model Override",
|
||||
"hooks.gmail.thinking": "Gmail Hook Thinking Override",
|
||||
"hooks.internal": "Internal Hooks",
|
||||
"hooks.internal.enabled": "Internal Hooks Enabled",
|
||||
"hooks.internal.handlers": "Internal Hook Handlers",
|
||||
"hooks.internal.handlers[].event": "Internal Hook Event",
|
||||
"hooks.internal.handlers[].module": "Internal Hook Module",
|
||||
"hooks.internal.handlers[].export": "Internal Hook Export",
|
||||
"hooks.internal.entries": "Internal Hook Entries",
|
||||
"hooks.internal.load": "Internal Hook Loader",
|
||||
"hooks.internal.load.extraDirs": "Internal Hook Extra Directories",
|
||||
"hooks.internal.installs": "Internal Hook Install Records",
|
||||
web: "Web Channel",
|
||||
"web.enabled": "Web Channel Enabled",
|
||||
"web.heartbeatSeconds": "Web Channel Heartbeat Interval (sec)",
|
||||
"web.reconnect": "Web Channel Reconnect Policy",
|
||||
"web.reconnect.initialMs": "Web Reconnect Initial Delay (ms)",
|
||||
"web.reconnect.maxMs": "Web Reconnect Max Delay (ms)",
|
||||
"web.reconnect.factor": "Web Reconnect Backoff Factor",
|
||||
"web.reconnect.jitter": "Web Reconnect Jitter",
|
||||
"web.reconnect.maxAttempts": "Web Reconnect Max Attempts",
|
||||
discovery: "Discovery",
|
||||
"discovery.wideArea": "Wide-area Discovery",
|
||||
"discovery.wideArea.enabled": "Wide-area Discovery Enabled",
|
||||
"discovery.mdns": "mDNS Discovery",
|
||||
canvasHost: "Canvas Host",
|
||||
"canvasHost.enabled": "Canvas Host Enabled",
|
||||
"canvasHost.root": "Canvas Host Root Directory",
|
||||
"canvasHost.port": "Canvas Host Port",
|
||||
"canvasHost.liveReload": "Canvas Host Live Reload",
|
||||
talk: "Talk",
|
||||
"talk.voiceId": "Talk Voice ID",
|
||||
"talk.voiceAliases": "Talk Voice Aliases",
|
||||
"talk.modelId": "Talk Model ID",
|
||||
"talk.outputFormat": "Talk Output Format",
|
||||
"talk.interruptOnSpeech": "Talk Interrupt on Speech",
|
||||
messages: "Messages",
|
||||
"messages.messagePrefix": "Inbound Message Prefix",
|
||||
"messages.responsePrefix": "Outbound Response Prefix",
|
||||
"messages.groupChat": "Group Chat Rules",
|
||||
"messages.groupChat.mentionPatterns": "Group Mention Patterns",
|
||||
"messages.groupChat.historyLimit": "Group History Limit",
|
||||
"messages.queue": "Inbound Queue",
|
||||
"messages.queue.mode": "Queue Mode",
|
||||
"messages.queue.byChannel": "Queue Mode by Channel",
|
||||
"messages.queue.debounceMs": "Queue Debounce (ms)",
|
||||
"messages.queue.debounceMsByChannel": "Queue Debounce by Channel (ms)",
|
||||
"messages.queue.cap": "Queue Capacity",
|
||||
"messages.queue.drop": "Queue Drop Strategy",
|
||||
"messages.inbound": "Inbound Debounce",
|
||||
"messages.suppressToolErrors": "Suppress Tool Error Warnings",
|
||||
"messages.ackReaction": "Ack Reaction Emoji",
|
||||
"messages.ackReactionScope": "Ack Reaction Scope",
|
||||
"messages.removeAckAfterReply": "Remove Ack Reaction After Reply",
|
||||
"messages.statusReactions": "Status Reactions",
|
||||
"messages.statusReactions.enabled": "Enable Status Reactions",
|
||||
"messages.statusReactions.emojis": "Status Reaction Emojis",
|
||||
"messages.statusReactions.timing": "Status Reaction Timing",
|
||||
"messages.inbound.debounceMs": "Inbound Message Debounce (ms)",
|
||||
"messages.inbound.byChannel": "Inbound Debounce by Channel (ms)",
|
||||
"messages.tts": "Message Text-to-Speech",
|
||||
"talk.apiKey": "Talk API Key",
|
||||
channels: "Channels",
|
||||
"channels.defaults": "Channel Defaults",
|
||||
"channels.defaults.groupPolicy": "Default Group Policy",
|
||||
"channels.defaults.heartbeat": "Default Heartbeat Visibility",
|
||||
"channels.defaults.heartbeat.showOk": "Heartbeat Show OK",
|
||||
"channels.defaults.heartbeat.showAlerts": "Heartbeat Show Alerts",
|
||||
"channels.defaults.heartbeat.useIndicator": "Heartbeat Use Indicator",
|
||||
"channels.whatsapp": "WhatsApp",
|
||||
"channels.telegram": "Telegram",
|
||||
"channels.telegram.customCommands": "Telegram Custom Commands",
|
||||
@@ -270,6 +603,9 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
...IRC_FIELD_LABELS,
|
||||
"channels.telegram.botToken": "Telegram Bot Token",
|
||||
"channels.telegram.dmPolicy": "Telegram DM Policy",
|
||||
"channels.telegram.configWrites": "Telegram Config Writes",
|
||||
"channels.telegram.commands.native": "Telegram Native Commands",
|
||||
"channels.telegram.commands.nativeSkills": "Telegram Native Skill Commands",
|
||||
"channels.telegram.streaming": "Telegram Streaming Mode",
|
||||
"channels.telegram.retry.attempts": "Telegram Retry Attempts",
|
||||
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
||||
@@ -281,11 +617,20 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
|
||||
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
|
||||
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
|
||||
"channels.whatsapp.configWrites": "WhatsApp Config Writes",
|
||||
"channels.signal.dmPolicy": "Signal DM Policy",
|
||||
"channels.signal.configWrites": "Signal Config Writes",
|
||||
"channels.imessage.dmPolicy": "iMessage DM Policy",
|
||||
"channels.imessage.configWrites": "iMessage Config Writes",
|
||||
"channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy",
|
||||
"channels.msteams.configWrites": "MS Teams Config Writes",
|
||||
"channels.irc.configWrites": "IRC Config Writes",
|
||||
"channels.discord.dmPolicy": "Discord DM Policy",
|
||||
"channels.discord.dm.policy": "Discord DM Policy",
|
||||
"channels.discord.configWrites": "Discord Config Writes",
|
||||
"channels.discord.proxy": "Discord Proxy URL",
|
||||
"channels.discord.commands.native": "Discord Native Commands",
|
||||
"channels.discord.commands.nativeSkills": "Discord Native Skill Commands",
|
||||
"channels.discord.streaming": "Discord Streaming Mode",
|
||||
"channels.discord.streamMode": "Discord Stream Mode (Legacy)",
|
||||
"channels.discord.draftChunk.minChars": "Discord Draft Chunk Min Chars",
|
||||
@@ -304,6 +649,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
|
||||
"channels.discord.voice.enabled": "Discord Voice Enabled",
|
||||
"channels.discord.voice.autoJoin": "Discord Voice Auto-Join",
|
||||
"channels.discord.voice.tts": "Discord Voice Text-to-Speech",
|
||||
"channels.discord.pluralkit.enabled": "Discord PluralKit Enabled",
|
||||
"channels.discord.pluralkit.token": "Discord PluralKit Token",
|
||||
"channels.discord.activity": "Discord Presence Activity",
|
||||
@@ -312,6 +658,9 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.discord.activityUrl": "Discord Presence Activity URL",
|
||||
"channels.slack.dm.policy": "Slack DM Policy",
|
||||
"channels.slack.dmPolicy": "Slack DM Policy",
|
||||
"channels.slack.configWrites": "Slack Config Writes",
|
||||
"channels.slack.commands.native": "Slack Native Commands",
|
||||
"channels.slack.commands.nativeSkills": "Slack Native Skill Commands",
|
||||
"channels.slack.allowBots": "Slack Allow Bot Messages",
|
||||
"channels.discord.token": "Discord Bot Token",
|
||||
"channels.slack.botToken": "Slack Bot Token",
|
||||
@@ -326,6 +675,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.slack.thread.initialHistoryLimit": "Slack Thread Initial History Limit",
|
||||
"channels.mattermost.botToken": "Mattermost Bot Token",
|
||||
"channels.mattermost.baseUrl": "Mattermost Base URL",
|
||||
"channels.mattermost.configWrites": "Mattermost Config Writes",
|
||||
"channels.mattermost.chatmode": "Mattermost Chat Mode",
|
||||
"channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes",
|
||||
"channels.mattermost.requireMention": "Mattermost Require Mention",
|
||||
@@ -333,15 +683,23 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.imessage.cliPath": "iMessage CLI Path",
|
||||
"agents.list[].skills": "Agent Skill Filter",
|
||||
"agents.list[].identity.avatar": "Agent Avatar",
|
||||
"agents.list[].heartbeat.suppressToolErrorWarnings":
|
||||
"Agent Heartbeat Suppress Tool Error Warnings",
|
||||
"agents.list[].sandbox.browser.network": "Agent Sandbox Browser Network",
|
||||
"agents.list[].sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Port Range",
|
||||
"discovery.mdns.mode": "mDNS Discovery Mode",
|
||||
plugins: "Plugins",
|
||||
"plugins.enabled": "Enable Plugins",
|
||||
"plugins.allow": "Plugin Allowlist",
|
||||
"plugins.deny": "Plugin Denylist",
|
||||
"plugins.load": "Plugin Loader",
|
||||
"plugins.load.paths": "Plugin Load Paths",
|
||||
"plugins.slots": "Plugin Slots",
|
||||
"plugins.slots.memory": "Memory Plugin",
|
||||
"plugins.entries": "Plugin Entries",
|
||||
"plugins.entries.*.enabled": "Plugin Enabled",
|
||||
"plugins.entries.*.apiKey": "Plugin API Key",
|
||||
"plugins.entries.*.env": "Plugin Environment Variables",
|
||||
"plugins.entries.*.config": "Plugin Config",
|
||||
"plugins.installs": "Plugin Install Records",
|
||||
"plugins.installs.*.source": "Plugin Install Source",
|
||||
|
||||
46
src/config/schema.tags.test.ts
Normal file
46
src/config/schema.tags.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildConfigSchema } from "./schema.js";
|
||||
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
|
||||
|
||||
describe("config schema tags", () => {
|
||||
it("derives security/auth tags for credential paths", () => {
|
||||
const tags = deriveTagsForPath("gateway.auth.token");
|
||||
expect(tags).toContain("security");
|
||||
expect(tags).toContain("auth");
|
||||
});
|
||||
|
||||
it("derives tools/performance tags for web fetch timeout paths", () => {
|
||||
const tags = deriveTagsForPath("tools.web.fetch.timeoutSeconds");
|
||||
expect(tags).toContain("tools");
|
||||
expect(tags).toContain("performance");
|
||||
});
|
||||
|
||||
it("keeps tags in the allowed taxonomy", () => {
|
||||
const withTags = applyDerivedTags({
|
||||
"gateway.auth.token": {},
|
||||
"tools.web.fetch.timeoutSeconds": {},
|
||||
"channels.slack.accounts.*.token": {},
|
||||
});
|
||||
const allowed = new Set<string>(CONFIG_TAGS);
|
||||
for (const hint of Object.values(withTags)) {
|
||||
for (const tag of hint.tags ?? []) {
|
||||
expect(allowed.has(tag)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("covers core/built-in config paths with tags", () => {
|
||||
const schema = buildConfigSchema();
|
||||
const allowed = new Set<string>(CONFIG_TAGS);
|
||||
for (const [key, hint] of Object.entries(schema.uiHints)) {
|
||||
if (!key.includes(".")) {
|
||||
continue;
|
||||
}
|
||||
const tags = hint.tags ?? [];
|
||||
expect(tags.length, `expected tags for ${key}`).toBeGreaterThan(0);
|
||||
for (const tag of tags) {
|
||||
expect(allowed.has(tag), `unexpected tag ${tag} on ${key}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
180
src/config/schema.tags.ts
Normal file
180
src/config/schema.tags.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||
|
||||
export const CONFIG_TAGS = [
|
||||
"security",
|
||||
"auth",
|
||||
"network",
|
||||
"access",
|
||||
"privacy",
|
||||
"observability",
|
||||
"performance",
|
||||
"reliability",
|
||||
"storage",
|
||||
"models",
|
||||
"media",
|
||||
"automation",
|
||||
"channels",
|
||||
"tools",
|
||||
"advanced",
|
||||
] as const;
|
||||
|
||||
export type ConfigTag = (typeof CONFIG_TAGS)[number];
|
||||
|
||||
const TAG_PRIORITY: Record<ConfigTag, number> = {
|
||||
security: 0,
|
||||
auth: 1,
|
||||
access: 2,
|
||||
network: 3,
|
||||
privacy: 4,
|
||||
observability: 5,
|
||||
reliability: 6,
|
||||
performance: 7,
|
||||
storage: 8,
|
||||
models: 9,
|
||||
media: 10,
|
||||
automation: 11,
|
||||
channels: 12,
|
||||
tools: 13,
|
||||
advanced: 14,
|
||||
};
|
||||
|
||||
const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
|
||||
"gateway.auth.token": ["security", "auth", "access", "network"],
|
||||
"gateway.auth.password": ["security", "auth", "access", "network"],
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"],
|
||||
"gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"],
|
||||
"tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"],
|
||||
};
|
||||
|
||||
const PREFIX_RULES: Array<{ prefix: string; tags: ConfigTag[] }> = [
|
||||
{ prefix: "channels.", tags: ["channels", "network"] },
|
||||
{ prefix: "tools.", tags: ["tools"] },
|
||||
{ prefix: "gateway.", tags: ["network"] },
|
||||
{ prefix: "nodehost.", tags: ["network"] },
|
||||
{ prefix: "discovery.", tags: ["network"] },
|
||||
{ prefix: "auth.", tags: ["auth", "access"] },
|
||||
{ prefix: "memory.", tags: ["storage"] },
|
||||
{ prefix: "models.", tags: ["models"] },
|
||||
{ prefix: "diagnostics.", tags: ["observability"] },
|
||||
{ prefix: "logging.", tags: ["observability"] },
|
||||
{ prefix: "cron.", tags: ["automation"] },
|
||||
{ prefix: "talk.", tags: ["media"] },
|
||||
{ prefix: "audio.", tags: ["media"] },
|
||||
];
|
||||
|
||||
const KEYWORD_RULES: Array<{ pattern: RegExp; tags: ConfigTag[] }> = [
|
||||
{ pattern: /(token|password|secret|api[_.-]?key|tlsfingerprint)/i, tags: ["security", "auth"] },
|
||||
{ pattern: /(allow|deny|owner|permission|policy|access)/i, tags: ["access"] },
|
||||
{ pattern: /(timeout|debounce|interval|concurrency|max|limit|cachettl)/i, tags: ["performance"] },
|
||||
{ pattern: /(retry|backoff|fallback|circuit|health|reload|probe)/i, tags: ["reliability"] },
|
||||
{ pattern: /(path|dir|file|store|db|session|cache)/i, tags: ["storage"] },
|
||||
{ pattern: /(telemetry|trace|metrics|logs|diagnostic)/i, tags: ["observability"] },
|
||||
{ pattern: /(experimental|dangerously|insecure)/i, tags: ["advanced", "security"] },
|
||||
{ pattern: /(privacy|redact|sanitize|anonym|pseudonym)/i, tags: ["privacy"] },
|
||||
];
|
||||
|
||||
const MODEL_PATH_PATTERN = /(^|\.)(model|models|modelid|imagemodel)(\.|$)/i;
|
||||
const MEDIA_PATH_PATTERN = /(tools\.media\.|^audio\.|^talk\.|image|video|stt|tts)/i;
|
||||
const AUTOMATION_PATH_PATTERN = /(cron|heartbeat|schedule|onstart|watchdebounce)/i;
|
||||
const AUTH_KEYWORD_PATTERN = /(token|password|secret|api[_.-]?key|credential|oauth)/i;
|
||||
|
||||
function normalizeTag(tag: string): ConfigTag | null {
|
||||
const normalized = tag.trim().toLowerCase() as ConfigTag;
|
||||
return CONFIG_TAGS.includes(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeTags(tags: ReadonlyArray<string>): ConfigTag[] {
|
||||
const out = new Set<ConfigTag>();
|
||||
for (const tag of tags) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (normalized) {
|
||||
out.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...out].toSorted((a, b) => TAG_PRIORITY[a] - TAG_PRIORITY[b]);
|
||||
}
|
||||
|
||||
function patternToRegExp(pattern: string): RegExp {
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^.]+");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
function resolveOverride(path: string): ConfigTag[] | undefined {
|
||||
const direct = TAG_OVERRIDES[path];
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const [pattern, tags] of Object.entries(TAG_OVERRIDES)) {
|
||||
if (!pattern.includes("*")) {
|
||||
continue;
|
||||
}
|
||||
if (patternToRegExp(pattern).test(path)) {
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function addTags(set: Set<ConfigTag>, tags: ReadonlyArray<ConfigTag>): void {
|
||||
for (const tag of tags) {
|
||||
set.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveTagsForPath(path: string, hint?: ConfigUiHint): ConfigTag[] {
|
||||
const lowerPath = path.toLowerCase();
|
||||
const override = resolveOverride(path);
|
||||
if (override) {
|
||||
return normalizeTags(override);
|
||||
}
|
||||
|
||||
const tags = new Set<ConfigTag>();
|
||||
for (const rule of PREFIX_RULES) {
|
||||
if (lowerPath.startsWith(rule.prefix)) {
|
||||
addTags(tags, rule.tags);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of KEYWORD_RULES) {
|
||||
if (rule.pattern.test(path)) {
|
||||
addTags(tags, rule.tags);
|
||||
}
|
||||
}
|
||||
|
||||
if (MODEL_PATH_PATTERN.test(path)) {
|
||||
tags.add("models");
|
||||
}
|
||||
if (MEDIA_PATH_PATTERN.test(path)) {
|
||||
tags.add("media");
|
||||
}
|
||||
if (AUTOMATION_PATH_PATTERN.test(path)) {
|
||||
tags.add("automation");
|
||||
}
|
||||
|
||||
if (hint?.sensitive) {
|
||||
tags.add("security");
|
||||
if (AUTH_KEYWORD_PATTERN.test(path)) {
|
||||
tags.add("auth");
|
||||
}
|
||||
}
|
||||
if (hint?.advanced) {
|
||||
tags.add("advanced");
|
||||
}
|
||||
|
||||
if (tags.size === 0) {
|
||||
tags.add("advanced");
|
||||
}
|
||||
|
||||
return normalizeTags([...tags]);
|
||||
}
|
||||
|
||||
export function applyDerivedTags(hints: ConfigUiHints): ConfigUiHints {
|
||||
const next: ConfigUiHints = {};
|
||||
for (const [path, hint] of Object.entries(hints)) {
|
||||
const existingTags = Array.isArray(hint?.tags) ? hint.tags : [];
|
||||
const derivedTags = deriveTagsForPath(path, hint);
|
||||
const tags = normalizeTags([...derivedTags, ...existingTags]);
|
||||
next[path] = { ...hint, tags };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||
import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js";
|
||||
import { applyDerivedTags } from "./schema.tags.js";
|
||||
import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||
@@ -75,7 +76,7 @@ export type PluginUiMetadata = {
|
||||
description?: string;
|
||||
configUiHints?: Record<
|
||||
string,
|
||||
Pick<ConfigUiHint, "label" | "help" | "advanced" | "sensitive" | "placeholder">
|
||||
Pick<ConfigUiHint, "label" | "help" | "tags" | "advanced" | "sensitive" | "placeholder">
|
||||
>;
|
||||
configSchema?: JsonSchemaNode;
|
||||
};
|
||||
@@ -327,7 +328,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
|
||||
unrepresentable: "any",
|
||||
});
|
||||
schema.title = "OpenClawConfig";
|
||||
const hints = mapSensitivePaths(OpenClawSchema, "", buildBaseHints());
|
||||
const hints = applyDerivedTags(mapSensitivePaths(OpenClawSchema, "", buildBaseHints()));
|
||||
const next = {
|
||||
schema: stripChannelSchema(schema),
|
||||
uiHints: hints,
|
||||
@@ -357,7 +358,9 @@ export function buildConfigSchema(params?: {
|
||||
plugins,
|
||||
channels,
|
||||
);
|
||||
const mergedHints = applySensitiveHints(mergedWithoutSensitiveHints, extensionHintKeys);
|
||||
const mergedHints = applyDerivedTags(
|
||||
applySensitiveHints(mergedWithoutSensitiveHints, extensionHintKeys),
|
||||
);
|
||||
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -41,6 +41,7 @@ export const ConfigUiHintSchema = Type.Object(
|
||||
{
|
||||
label: Type.Optional(Type.String()),
|
||||
help: Type.Optional(Type.String()),
|
||||
tags: Type.Optional(Type.Array(Type.String())),
|
||||
group: Type.Optional(Type.String()),
|
||||
order: Type.Optional(Type.Integer()),
|
||||
advanced: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
|
||||
|
||||
type VerifyDeviceTokenFn = Parameters<typeof resolveConnectAuthDecision>[0]["verifyDeviceToken"];
|
||||
|
||||
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
|
||||
limiter: AuthRateLimiter;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
@@ -35,7 +37,7 @@ function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthStat
|
||||
}
|
||||
|
||||
async function resolveDeviceTokenDecision(params: {
|
||||
verifyDeviceToken: ReturnType<typeof vi.fn>;
|
||||
verifyDeviceToken: VerifyDeviceTokenFn;
|
||||
stateOverrides?: Partial<ConnectAuthState>;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
clientIp?: string;
|
||||
@@ -54,7 +56,7 @@ async function resolveDeviceTokenDecision(params: {
|
||||
|
||||
describe("resolveConnectAuthDecision", () => {
|
||||
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false }));
|
||||
const decision = await resolveConnectAuthDecision({
|
||||
state: createBaseState(),
|
||||
hasDeviceIdentity: true,
|
||||
@@ -69,7 +71,7 @@ describe("resolveConnectAuthDecision", () => {
|
||||
});
|
||||
|
||||
it("reports explicit device-token mismatches as device_token_mismatch", async () => {
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false }));
|
||||
const decision = await resolveConnectAuthDecision({
|
||||
state: createBaseState({
|
||||
deviceTokenCandidateSource: "explicit-device-token",
|
||||
@@ -86,7 +88,7 @@ describe("resolveConnectAuthDecision", () => {
|
||||
|
||||
it("accepts valid device tokens and marks auth method as device-token", async () => {
|
||||
const rateLimiter = createRateLimiter();
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
const decision = await resolveDeviceTokenDecision({
|
||||
verifyDeviceToken,
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
@@ -100,7 +102,7 @@ describe("resolveConnectAuthDecision", () => {
|
||||
|
||||
it("returns rate-limited auth result without verifying device token", async () => {
|
||||
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
const decision = await resolveDeviceTokenDecision({
|
||||
verifyDeviceToken,
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
@@ -113,7 +115,7 @@ describe("resolveConnectAuthDecision", () => {
|
||||
});
|
||||
|
||||
it("returns the original decision when device fallback does not apply", async () => {
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
const decision = await resolveConnectAuthDecision({
|
||||
state: createBaseState({
|
||||
authResult: { ok: true, method: "token" },
|
||||
|
||||
@@ -29,6 +29,7 @@ export type PluginLogger = {
|
||||
export type PluginConfigUiHint = {
|
||||
label?: string;
|
||||
help?: string;
|
||||
tags?: string[];
|
||||
advanced?: boolean;
|
||||
sensitive?: boolean;
|
||||
placeholder?: string;
|
||||
|
||||
@@ -72,12 +72,19 @@ export function resolveSlackThreadTs(params: {
|
||||
incomingThreadTs: string | undefined;
|
||||
messageTs: string | undefined;
|
||||
hasReplied: boolean;
|
||||
isThreadReply?: boolean;
|
||||
}): string | undefined {
|
||||
const isThreadReply =
|
||||
params.isThreadReply ??
|
||||
(typeof params.incomingThreadTs === "string" &&
|
||||
params.incomingThreadTs.length > 0 &&
|
||||
params.incomingThreadTs !== params.messageTs);
|
||||
const planner = createSlackReplyReferencePlanner({
|
||||
replyToMode: params.replyToMode,
|
||||
incomingThreadTs: params.incomingThreadTs,
|
||||
messageTs: params.messageTs,
|
||||
hasReplied: params.hasReplied,
|
||||
isThreadReply,
|
||||
});
|
||||
return planner.use();
|
||||
}
|
||||
|
||||
@@ -59,13 +59,13 @@ function createHarness(params?: {
|
||||
|
||||
describe("tui command handlers", () => {
|
||||
it("renders the sending indicator before chat.send resolves", async () => {
|
||||
let resolveSend: ((value: { runId: string }) => void) | null = null;
|
||||
const sendChat = vi.fn(
|
||||
() =>
|
||||
new Promise<{ runId: string }>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
let resolveSend: (value: { runId: string }) => void = () => {
|
||||
throw new Error("sendChat promise resolver was not initialized");
|
||||
};
|
||||
const sendPromise = new Promise<{ runId: string }>((resolve) => {
|
||||
resolveSend = (value) => resolve(value);
|
||||
});
|
||||
const sendChat = vi.fn(() => sendPromise);
|
||||
const setActivityStatus = vi.fn();
|
||||
|
||||
const { handleCommand, requestRender } = createHarness({
|
||||
@@ -81,10 +81,7 @@ describe("tui command handlers", () => {
|
||||
const renderOrders = requestRender.mock.invocationCallOrder;
|
||||
expect(renderOrders.some((order) => order > sendingOrder)).toBe(true);
|
||||
|
||||
if (typeof resolveSend !== "function") {
|
||||
throw new Error("expected sendChat to be pending");
|
||||
}
|
||||
(resolveSend as (value: { runId: string }) => void)({ runId: "r1" });
|
||||
resolveSend({ runId: "r1" });
|
||||
await pending;
|
||||
expect(setActivityStatus).toHaveBeenCalledWith("waiting");
|
||||
});
|
||||
|
||||
@@ -30,12 +30,24 @@ const envSnapshot = () => ({
|
||||
PATH: process.env.PATH,
|
||||
SHERPA_ONNX_MODEL_DIR: process.env.SHERPA_ONNX_MODEL_DIR,
|
||||
WHISPER_CPP_MODEL: process.env.WHISPER_CPP_MODEL,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
GROQ_API_KEY: process.env.GROQ_API_KEY,
|
||||
DEEPGRAM_API_KEY: process.env.DEEPGRAM_API_KEY,
|
||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
|
||||
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
|
||||
});
|
||||
|
||||
const restoreEnv = (snapshot: ReturnType<typeof envSnapshot>) => {
|
||||
process.env.PATH = snapshot.PATH;
|
||||
process.env.SHERPA_ONNX_MODEL_DIR = snapshot.SHERPA_ONNX_MODEL_DIR;
|
||||
process.env.WHISPER_CPP_MODEL = snapshot.WHISPER_CPP_MODEL;
|
||||
process.env.OPENAI_API_KEY = snapshot.OPENAI_API_KEY;
|
||||
process.env.GROQ_API_KEY = snapshot.GROQ_API_KEY;
|
||||
process.env.DEEPGRAM_API_KEY = snapshot.DEEPGRAM_API_KEY;
|
||||
process.env.GEMINI_API_KEY = snapshot.GEMINI_API_KEY;
|
||||
process.env.OPENCLAW_AGENT_DIR = snapshot.OPENCLAW_AGENT_DIR;
|
||||
process.env.PI_CODING_AGENT_DIR = snapshot.PI_CODING_AGENT_DIR;
|
||||
};
|
||||
|
||||
const withEnvSnapshot = async <T>(run: () => Promise<T>): Promise<T> => {
|
||||
@@ -176,9 +188,16 @@ describe("media understanding auto-detect (e2e)", () => {
|
||||
it("skips auto-detect when no supported binaries are available", async () => {
|
||||
await withEnvSnapshot(async () => {
|
||||
const emptyBinDir = await createTrackedTempDir(tempPaths, "openclaw-bin-empty-");
|
||||
const isolatedAgentDir = await createTrackedTempDir(tempPaths, "openclaw-agent-empty-");
|
||||
process.env.PATH = emptyBinDir;
|
||||
delete process.env.SHERPA_ONNX_MODEL_DIR;
|
||||
delete process.env.WHISPER_CPP_MODEL;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.GROQ_API_KEY;
|
||||
delete process.env.DEEPGRAM_API_KEY;
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
process.env.OPENCLAW_AGENT_DIR = isolatedAgentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = isolatedAgentDir;
|
||||
|
||||
const filePath = await createTrackedTempMedia(tempPaths, ".wav");
|
||||
const ctx: MsgContext = {
|
||||
|
||||
@@ -53,14 +53,19 @@
|
||||
|
||||
/* Search */
|
||||
.config-search {
|
||||
position: relative;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.config-search__input-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.config-search__icon {
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
@@ -103,7 +108,7 @@
|
||||
|
||||
.config-search__clear {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 22px;
|
||||
@@ -128,6 +133,131 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-search__hint {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.config-search__hint-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-search__tag-picker {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
box-shadow var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.config-search__tag-picker[open] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-search__tag-picker {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.config-search__tag-trigger {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-search__tag-trigger::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-search__tag-placeholder {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.config-search__tag-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-search__tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 2px 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.config-search__tag-chip--count {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.config-search__tag-caret {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.config-search__tag-picker[open] .config-search__tag-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.config-search__tag-menu {
|
||||
max-height: 104px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.config-search__tag-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.config-search__tag-option:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-search__tag-option.active {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.config-nav {
|
||||
flex: 1;
|
||||
@@ -536,7 +666,7 @@
|
||||
|
||||
.config-form--modern {
|
||||
display: grid;
|
||||
gap: 26px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.config-section-card {
|
||||
@@ -603,7 +733,7 @@
|
||||
}
|
||||
|
||||
.config-section-card__content {
|
||||
padding: 22px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
@@ -612,12 +742,16 @@
|
||||
|
||||
.cfg-fields {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cfg-fields--inline {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cfg-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cfg-field--error {
|
||||
@@ -639,6 +773,28 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cfg-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cfg-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
background: var(--bg-elevated);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-tag {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-field__error {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
@@ -989,22 +1145,25 @@
|
||||
.cfg-object {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-accent);
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-object {
|
||||
background: white;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cfg-object__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
transition: background var(--duration-fast) ease;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.cfg-object__header:hover {
|
||||
@@ -1021,6 +1180,12 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cfg-object__title-wrap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cfg-object__chevron {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -1038,16 +1203,16 @@
|
||||
}
|
||||
|
||||
.cfg-object__help {
|
||||
padding: 0 18px 14px;
|
||||
padding: 0 12px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cfg-object__content {
|
||||
padding: 18px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Array */
|
||||
@@ -1061,7 +1226,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -1071,12 +1236,18 @@
|
||||
}
|
||||
|
||||
.cfg-array__label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cfg-array__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cfg-array__count {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
@@ -1124,7 +1295,7 @@
|
||||
}
|
||||
|
||||
.cfg-array__help {
|
||||
padding: 12px 18px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -1151,7 +1322,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 18px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -1200,7 +1371,7 @@
|
||||
}
|
||||
|
||||
.cfg-array__item-content {
|
||||
padding: 18px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Map (custom entries) */
|
||||
@@ -1215,7 +1386,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -1268,15 +1439,28 @@
|
||||
|
||||
.cfg-map__items {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.cfg-map__item {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-accent);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-map__item {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-map__item-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 300px) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cfg-map__item-key {
|
||||
@@ -1287,9 +1471,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cfg-map__item-value > .cfg-fields {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cfg-map__item-remove {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1410,6 +1598,10 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cfg-map__item-header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.cfg-map__item-remove {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@@ -197,6 +197,113 @@ describe("config form renderer", () => {
|
||||
expect(container.textContent).toContain("Plugin Enabled");
|
||||
});
|
||||
|
||||
it("renders tags from uiHints metadata", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security", "secret"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const tags = Array.from(container.querySelectorAll(".cfg-tag")).map((node) =>
|
||||
node.textContent?.trim(),
|
||||
);
|
||||
expect(tags).toContain("security");
|
||||
expect(tags).toContain("secret");
|
||||
});
|
||||
|
||||
it("filters by tag query", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
searchQuery: "tag:security",
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Gateway");
|
||||
expect(container.textContent).toContain("Token");
|
||||
expect(container.textContent).not.toContain("Allow From");
|
||||
expect(container.textContent).not.toContain("Mode");
|
||||
});
|
||||
|
||||
it("does not treat plain text as tag filter", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
searchQuery: "security",
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('No settings match "security"');
|
||||
});
|
||||
|
||||
it("requires both text and tag when combined", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
searchQuery: "token tag:security",
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Token");
|
||||
expect(container.textContent).not.toContain('No settings match "token tag:security"');
|
||||
|
||||
const noMatchContainer = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
searchQuery: "mode tag:security",
|
||||
onPatch,
|
||||
}),
|
||||
noMatchContainer,
|
||||
);
|
||||
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
|
||||
});
|
||||
|
||||
it("flags unsupported unions", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
|
||||
@@ -286,6 +286,7 @@ export type ConfigSnapshot = {
|
||||
export type ConfigUiHint = {
|
||||
label?: string;
|
||||
help?: string;
|
||||
tags?: string[];
|
||||
group?: string;
|
||||
order?: number;
|
||||
advanced?: boolean;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared.ts";
|
||||
|
||||
const META_KEYS = new Set(["title", "description", "default", "nullable"]);
|
||||
const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]);
|
||||
|
||||
function isAnySchema(schema: JsonSchema): boolean {
|
||||
const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key));
|
||||
@@ -94,6 +94,234 @@ const icons = {
|
||||
`,
|
||||
};
|
||||
|
||||
type FieldMeta = {
|
||||
label: string;
|
||||
help?: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type ConfigSearchCriteria = {
|
||||
text: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
|
||||
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
|
||||
}
|
||||
|
||||
export function parseConfigSearchQuery(query: string): ConfigSearchCriteria {
|
||||
const tags: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const raw = query.trim();
|
||||
const stripped = raw.replace(/(^|\s)tag:([^\s]+)/gi, (_, leading: string, token: string) => {
|
||||
const normalized = token.trim().toLowerCase();
|
||||
if (normalized && !seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
tags.push(normalized);
|
||||
}
|
||||
return leading;
|
||||
});
|
||||
return {
|
||||
text: stripped.trim().toLowerCase(),
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTags(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const value of raw) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const tag = value.trim();
|
||||
if (!tag) {
|
||||
continue;
|
||||
}
|
||||
const key = tag.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function resolveFieldMeta(
|
||||
path: Array<string | number>,
|
||||
schema: JsonSchema,
|
||||
hints: ConfigUiHints,
|
||||
): FieldMeta {
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const schemaTags = normalizeTags(schema["x-tags"] ?? schema.tags);
|
||||
const hintTags = normalizeTags(hint?.tags);
|
||||
return {
|
||||
label,
|
||||
help,
|
||||
tags: hintTags.length > 0 ? hintTags : schemaTags,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesText(text: string, candidates: Array<string | undefined>): boolean {
|
||||
if (!text) {
|
||||
return true;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && candidate.toLowerCase().includes(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchesTags(filterTags: string[], fieldTags: string[]): boolean {
|
||||
if (filterTags.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const normalized = new Set(fieldTags.map((tag) => tag.toLowerCase()));
|
||||
return filterTags.every((tag) => normalized.has(tag));
|
||||
}
|
||||
|
||||
function matchesNodeSelf(params: {
|
||||
schema: JsonSchema;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
criteria: ConfigSearchCriteria;
|
||||
}): boolean {
|
||||
const { schema, path, hints, criteria } = params;
|
||||
if (!hasSearchCriteria(criteria)) {
|
||||
return true;
|
||||
}
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
if (!matchesTags(criteria.tags, tags)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!criteria.text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pathLabel = path
|
||||
.filter((segment): segment is string => typeof segment === "string")
|
||||
.join(".");
|
||||
const enumText =
|
||||
schema.enum && schema.enum.length > 0
|
||||
? schema.enum.map((value) => String(value)).join(" ")
|
||||
: "";
|
||||
|
||||
return matchesText(criteria.text, [
|
||||
label,
|
||||
help,
|
||||
schema.title,
|
||||
schema.description,
|
||||
pathLabel,
|
||||
enumText,
|
||||
]);
|
||||
}
|
||||
|
||||
export function matchesNodeSearch(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
criteria: ConfigSearchCriteria;
|
||||
}): boolean {
|
||||
const { schema, value, path, hints, criteria } = params;
|
||||
if (!hasSearchCriteria(criteria)) {
|
||||
return true;
|
||||
}
|
||||
if (matchesNodeSelf({ schema, path, hints, criteria })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const type = schemaType(schema);
|
||||
if (type === "object") {
|
||||
const fallback = value ?? schema.default;
|
||||
const obj =
|
||||
fallback && typeof fallback === "object" && !Array.isArray(fallback)
|
||||
? (fallback as Record<string, unknown>)
|
||||
: {};
|
||||
const props = schema.properties ?? {};
|
||||
for (const [propKey, node] of Object.entries(props)) {
|
||||
if (
|
||||
matchesNodeSearch({
|
||||
schema: node,
|
||||
value: obj[propKey],
|
||||
path: [...path, propKey],
|
||||
hints,
|
||||
criteria,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const additional = schema.additionalProperties;
|
||||
if (additional && typeof additional === "object") {
|
||||
const reserved = new Set(Object.keys(props));
|
||||
for (const [entryKey, entryValue] of Object.entries(obj)) {
|
||||
if (reserved.has(entryKey)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
matchesNodeSearch({
|
||||
schema: additional,
|
||||
value: entryValue,
|
||||
path: [...path, entryKey],
|
||||
hints,
|
||||
criteria,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === "array") {
|
||||
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
||||
if (!itemsSchema) {
|
||||
return false;
|
||||
}
|
||||
const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : [];
|
||||
if (arr.length === 0) {
|
||||
return false;
|
||||
}
|
||||
for (let idx = 0; idx < arr.length; idx += 1) {
|
||||
if (
|
||||
matchesNodeSearch({
|
||||
schema: itemsSchema,
|
||||
value: arr[idx],
|
||||
path: [...path, idx],
|
||||
hints,
|
||||
criteria,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderTags(tags: string[]): TemplateResult | typeof nothing {
|
||||
if (tags.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="cfg-tags">
|
||||
${tags.map((tag) => html`<span class="cfg-tag">${tag}</span>`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderNode(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
@@ -102,15 +330,15 @@ export function renderNode(params: {
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult | typeof nothing {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const type = schemaType(schema);
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const key = pathKey(path);
|
||||
const criteria = params.searchCriteria;
|
||||
|
||||
if (unsupported.has(key)) {
|
||||
return html`<div class="cfg-field cfg-field--error">
|
||||
@@ -118,6 +346,13 @@ export function renderNode(params: {
|
||||
<div class="cfg-field__error">Unsupported schema node. Use Raw mode.</div>
|
||||
</div>`;
|
||||
}
|
||||
if (
|
||||
criteria &&
|
||||
hasSearchCriteria(criteria) &&
|
||||
!matchesNodeSearch({ schema, value, path, hints, criteria })
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf unions
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
@@ -150,6 +385,7 @@ export function renderNode(params: {
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
${renderTags(tags)}
|
||||
<div class="cfg-segmented">
|
||||
${literals.map(
|
||||
(lit) => html`
|
||||
@@ -215,6 +451,7 @@ export function renderNode(params: {
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
${renderTags(tags)}
|
||||
<div class="cfg-segmented">
|
||||
${options.map(
|
||||
(opt) => html`
|
||||
@@ -258,6 +495,7 @@ export function renderNode(params: {
|
||||
<div class="cfg-toggle-row__content">
|
||||
<span class="cfg-toggle-row__label">${label}</span>
|
||||
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
|
||||
${renderTags(tags)}
|
||||
</div>
|
||||
<div class="cfg-toggle">
|
||||
<input
|
||||
@@ -298,14 +536,14 @@ function renderTextInput(params: {
|
||||
hints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
inputType: "text" | "number";
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const isSensitive =
|
||||
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
|
||||
const placeholder =
|
||||
@@ -322,6 +560,7 @@ function renderTextInput(params: {
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
${renderTags(tags)}
|
||||
<div class="cfg-input-wrap">
|
||||
<input
|
||||
type=${isSensitive ? "password" : inputType}
|
||||
@@ -375,13 +614,12 @@ function renderNumberInput(params: {
|
||||
hints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const displayValue = value ?? schema.default ?? "";
|
||||
const numValue = typeof displayValue === "number" ? displayValue : 0;
|
||||
|
||||
@@ -389,6 +627,7 @@ function renderNumberInput(params: {
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
${renderTags(tags)}
|
||||
<div class="cfg-number">
|
||||
<button
|
||||
type="button"
|
||||
@@ -425,14 +664,13 @@ function renderSelect(params: {
|
||||
hints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
options: unknown[];
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, options, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const resolvedValue = value ?? schema.default;
|
||||
const currentIndex = options.findIndex(
|
||||
(opt) => opt === resolvedValue || String(opt) === String(resolvedValue),
|
||||
@@ -443,6 +681,7 @@ function renderSelect(params: {
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
${renderTags(tags)}
|
||||
<select
|
||||
class="cfg-select"
|
||||
?disabled=${disabled}
|
||||
@@ -471,12 +710,17 @@ function renderObject(params: {
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const selfMatched =
|
||||
searchCriteria && hasSearchCriteria(searchCriteria)
|
||||
? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria })
|
||||
: false;
|
||||
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
|
||||
|
||||
const fallback = value ?? schema.default;
|
||||
const obj =
|
||||
@@ -509,6 +753,7 @@ function renderObject(params: {
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
searchCriteria: childSearchCriteria,
|
||||
onPatch,
|
||||
}),
|
||||
)}
|
||||
@@ -522,6 +767,7 @@ function renderObject(params: {
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
searchCriteria: childSearchCriteria,
|
||||
onPatch,
|
||||
})
|
||||
: nothing
|
||||
@@ -537,11 +783,22 @@ function renderObject(params: {
|
||||
`;
|
||||
}
|
||||
|
||||
if (!showLabel) {
|
||||
return html`
|
||||
<div class="cfg-fields cfg-fields--inline">
|
||||
${fields}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Nested objects get collapsible treatment
|
||||
return html`
|
||||
<details class="cfg-object" open>
|
||||
<details class="cfg-object" ?open=${path.length <= 2}>
|
||||
<summary class="cfg-object__header">
|
||||
<span class="cfg-object__title">${label}</span>
|
||||
<span class="cfg-object__title-wrap">
|
||||
<span class="cfg-object__title">${label}</span>
|
||||
${renderTags(tags)}
|
||||
</span>
|
||||
<span class="cfg-object__chevron">${icons.chevronDown}</span>
|
||||
</summary>
|
||||
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
|
||||
@@ -560,13 +817,17 @@ function renderArray(params: {
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const selfMatched =
|
||||
searchCriteria && hasSearchCriteria(searchCriteria)
|
||||
? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria })
|
||||
: false;
|
||||
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
|
||||
|
||||
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
||||
if (!itemsSchema) {
|
||||
@@ -583,7 +844,10 @@ function renderArray(params: {
|
||||
return html`
|
||||
<div class="cfg-array">
|
||||
<div class="cfg-array__header">
|
||||
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
|
||||
<div class="cfg-array__title">
|
||||
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
|
||||
${renderTags(tags)}
|
||||
</div>
|
||||
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? "s" : ""}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -634,6 +898,7 @@ function renderArray(params: {
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
searchCriteria: childSearchCriteria,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})}
|
||||
@@ -656,11 +921,34 @@ function renderMapField(params: {
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
reservedKeys: Set<string>;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } = params;
|
||||
const {
|
||||
schema,
|
||||
value,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys,
|
||||
onPatch,
|
||||
searchCriteria,
|
||||
} = params;
|
||||
const anySchema = isAnySchema(schema);
|
||||
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
|
||||
const visibleEntries =
|
||||
searchCriteria && hasSearchCriteria(searchCriteria)
|
||||
? entries.filter(([key, entryValue]) =>
|
||||
matchesNodeSearch({
|
||||
schema,
|
||||
value: entryValue,
|
||||
path: [...path, key],
|
||||
hints,
|
||||
criteria: searchCriteria,
|
||||
}),
|
||||
)
|
||||
: entries;
|
||||
|
||||
return html`
|
||||
<div class="cfg-map">
|
||||
@@ -688,38 +976,53 @@ function renderMapField(params: {
|
||||
</div>
|
||||
|
||||
${
|
||||
entries.length === 0
|
||||
visibleEntries.length === 0
|
||||
? html`
|
||||
<div class="cfg-map__empty">No custom entries.</div>
|
||||
`
|
||||
: html`
|
||||
<div class="cfg-map__items">
|
||||
${entries.map(([key, entryValue]) => {
|
||||
${visibleEntries.map(([key, entryValue]) => {
|
||||
const valuePath = [...path, key];
|
||||
const fallback = jsonValue(entryValue);
|
||||
return html`
|
||||
<div class="cfg-map__item">
|
||||
<div class="cfg-map__item-key">
|
||||
<input
|
||||
type="text"
|
||||
class="cfg-input cfg-input--sm"
|
||||
placeholder="Key"
|
||||
.value=${key}
|
||||
<div class="cfg-map__item-header">
|
||||
<div class="cfg-map__item-key">
|
||||
<input
|
||||
type="text"
|
||||
class="cfg-input cfg-input--sm"
|
||||
placeholder="Key"
|
||||
.value=${key}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const nextKey = (e.target as HTMLInputElement).value.trim();
|
||||
if (!nextKey || nextKey === key) {
|
||||
return;
|
||||
}
|
||||
const next = { ...value };
|
||||
if (nextKey in next) {
|
||||
return;
|
||||
}
|
||||
next[nextKey] = next[key];
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-map__item-remove"
|
||||
title="Remove entry"
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const nextKey = (e.target as HTMLInputElement).value.trim();
|
||||
if (!nextKey || nextKey === key) {
|
||||
return;
|
||||
}
|
||||
@click=${() => {
|
||||
const next = { ...value };
|
||||
if (nextKey in next) {
|
||||
return;
|
||||
}
|
||||
next[nextKey] = next[key];
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
${icons.trash}
|
||||
</button>
|
||||
</div>
|
||||
<div class="cfg-map__item-value">
|
||||
${
|
||||
@@ -753,24 +1056,12 @@ function renderMapField(params: {
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
searchCriteria,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-map__item-remove"
|
||||
title="Remove entry"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = { ...value };
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
${icons.trash}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import { renderNode } from "./config-form.node.ts";
|
||||
import { matchesNodeSearch, parseConfigSearchQuery, renderNode } from "./config-form.node.ts";
|
||||
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
|
||||
|
||||
export type ConfigFormProps = {
|
||||
@@ -278,20 +278,27 @@ function getSectionIcon(key: string) {
|
||||
return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default;
|
||||
}
|
||||
|
||||
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
|
||||
if (!query) {
|
||||
function matchesSearch(params: {
|
||||
key: string;
|
||||
schema: JsonSchema;
|
||||
sectionValue: unknown;
|
||||
uiHints: ConfigUiHints;
|
||||
query: string;
|
||||
}): boolean {
|
||||
if (!params.query) {
|
||||
return true;
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
const meta = SECTION_META[key];
|
||||
const criteria = parseConfigSearchQuery(params.query);
|
||||
const q = criteria.text;
|
||||
const meta = SECTION_META[params.key];
|
||||
|
||||
// Check key name
|
||||
if (key.toLowerCase().includes(q)) {
|
||||
if (q && params.key.toLowerCase().includes(q)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check label and description
|
||||
if (meta) {
|
||||
if (q && meta) {
|
||||
if (meta.label.toLowerCase().includes(q)) {
|
||||
return true;
|
||||
}
|
||||
@@ -300,56 +307,13 @@ function matchesSearch(key: string, schema: JsonSchema, query: string): boolean
|
||||
}
|
||||
}
|
||||
|
||||
return schemaMatches(schema, q);
|
||||
}
|
||||
|
||||
function schemaMatches(schema: JsonSchema, query: string): boolean {
|
||||
if (schema.title?.toLowerCase().includes(query)) {
|
||||
return true;
|
||||
}
|
||||
if (schema.description?.toLowerCase().includes(query)) {
|
||||
return true;
|
||||
}
|
||||
if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (schema.properties) {
|
||||
for (const [propKey, propSchema] of Object.entries(schema.properties)) {
|
||||
if (propKey.toLowerCase().includes(query)) {
|
||||
return true;
|
||||
}
|
||||
if (schemaMatches(propSchema, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.items) {
|
||||
const items = Array.isArray(schema.items) ? schema.items : [schema.items];
|
||||
for (const item of items) {
|
||||
if (item && schemaMatches(item, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
if (schemaMatches(schema.additionalProperties, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const unions = schema.anyOf ?? schema.oneOf ?? schema.allOf;
|
||||
if (unions) {
|
||||
for (const entry of unions) {
|
||||
if (entry && schemaMatches(entry, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return matchesNodeSearch({
|
||||
schema: params.schema,
|
||||
value: params.sectionValue,
|
||||
path: [params.key],
|
||||
hints: params.uiHints,
|
||||
criteria,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderConfigForm(props: ConfigFormProps) {
|
||||
@@ -368,6 +332,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
const unsupported = new Set(props.unsupportedPaths ?? []);
|
||||
const properties = schema.properties;
|
||||
const searchQuery = props.searchQuery ?? "";
|
||||
const searchCriteria = parseConfigSearchQuery(searchQuery);
|
||||
const activeSection = props.activeSection;
|
||||
const activeSubsection = props.activeSubsection ?? null;
|
||||
|
||||
@@ -384,7 +349,16 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
if (activeSection && key !== activeSection) {
|
||||
return false;
|
||||
}
|
||||
if (searchQuery && !matchesSearch(key, node, searchQuery)) {
|
||||
if (
|
||||
searchQuery &&
|
||||
!matchesSearch({
|
||||
key,
|
||||
schema: node,
|
||||
sectionValue: value[key],
|
||||
uiHints: props.uiHints,
|
||||
query: searchQuery,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -456,6 +430,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
unsupported,
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
searchCriteria,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
@@ -490,6 +465,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
unsupported,
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
searchCriteria,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
|
||||
69
ui/src/ui/views/config-form.search.node.test.ts
Normal file
69
ui/src/ui/views/config-form.search.node.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matchesNodeSearch, parseConfigSearchQuery } from "./config-form.node.ts";
|
||||
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
gateway: {
|
||||
type: "object",
|
||||
properties: {
|
||||
auth: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "token"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("config form search", () => {
|
||||
it("parses tag-prefixed query terms", () => {
|
||||
const parsed = parseConfigSearchQuery("token tag:security tag:Auth");
|
||||
expect(parsed.text).toBe("token");
|
||||
expect(parsed.tags).toEqual(["security", "auth"]);
|
||||
});
|
||||
|
||||
it("matches fields by tag through ui hints", () => {
|
||||
const parsed = parseConfigSearchQuery("tag:security");
|
||||
const matched = matchesNodeSearch({
|
||||
schema: schema.properties.gateway,
|
||||
value: {},
|
||||
path: ["gateway"],
|
||||
hints: {
|
||||
"gateway.auth.token": { tags: ["security", "secret"] },
|
||||
},
|
||||
criteria: parsed,
|
||||
});
|
||||
expect(matched).toBe(true);
|
||||
});
|
||||
|
||||
it("requires text and tag when combined", () => {
|
||||
const positive = matchesNodeSearch({
|
||||
schema: schema.properties.gateway,
|
||||
value: {},
|
||||
path: ["gateway"],
|
||||
hints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
criteria: parseConfigSearchQuery("token tag:security"),
|
||||
});
|
||||
expect(positive).toBe(true);
|
||||
|
||||
const negative = matchesNodeSearch({
|
||||
schema: schema.properties.gateway,
|
||||
value: {},
|
||||
path: ["gateway"],
|
||||
hints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
criteria: parseConfigSearchQuery("mode tag:security"),
|
||||
});
|
||||
expect(negative).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ export type JsonSchema = {
|
||||
type?: string | string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
"x-tags"?: string[];
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema | JsonSchema[];
|
||||
additionalProperties?: JsonSchema | boolean;
|
||||
|
||||
50
ui/src/ui/views/config-search.node.test.ts
Normal file
50
ui/src/ui/views/config-search.node.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendTagFilter,
|
||||
getTagFilters,
|
||||
hasTagFilter,
|
||||
removeTagFilter,
|
||||
replaceTagFilters,
|
||||
toggleTagFilter,
|
||||
} from "./config-search.ts";
|
||||
|
||||
describe("config search tag helper", () => {
|
||||
it("adds a tag when query is empty", () => {
|
||||
expect(appendTagFilter("", "security")).toBe("tag:security");
|
||||
});
|
||||
|
||||
it("appends a tag to existing text query", () => {
|
||||
expect(appendTagFilter("token", "security")).toBe("token tag:security");
|
||||
});
|
||||
|
||||
it("deduplicates existing tag filters case-insensitively", () => {
|
||||
expect(appendTagFilter("token tag:Security", "security")).toBe("token tag:Security");
|
||||
});
|
||||
|
||||
it("detects exact tag terms", () => {
|
||||
expect(hasTagFilter("tag:security token", "security")).toBe(true);
|
||||
expect(hasTagFilter("tag:security-hard token", "security")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes only the selected active tag", () => {
|
||||
expect(removeTagFilter("token tag:security tag:auth", "security")).toBe("token tag:auth");
|
||||
});
|
||||
|
||||
it("toggle removes active tag and keeps text", () => {
|
||||
expect(toggleTagFilter("token tag:security", "security")).toBe("token");
|
||||
});
|
||||
|
||||
it("toggle adds missing tag", () => {
|
||||
expect(toggleTagFilter("token", "channels")).toBe("token tag:channels");
|
||||
});
|
||||
|
||||
it("extracts unique normalized tags from query", () => {
|
||||
expect(getTagFilters("token tag:Security tag:auth tag:security")).toEqual(["security", "auth"]);
|
||||
});
|
||||
|
||||
it("replaces only tag filters and preserves free text", () => {
|
||||
expect(replaceTagFilters("token tag:security mode", ["auth", "channels"])).toBe(
|
||||
"token mode tag:auth tag:channels",
|
||||
);
|
||||
});
|
||||
});
|
||||
92
ui/src/ui/views/config-search.ts
Normal file
92
ui/src/ui/views/config-search.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function normalizeTag(tag: string): string {
|
||||
return tag.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function getTagFilters(query: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
const pattern = /(^|\s)tag:([^\s]+)/gi;
|
||||
const raw = query.trim();
|
||||
let match: RegExpExecArray | null = pattern.exec(raw);
|
||||
while (match) {
|
||||
const normalized = normalizeTag(match[2] ?? "");
|
||||
if (normalized && !seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
tags.push(normalized);
|
||||
}
|
||||
match = pattern.exec(raw);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function hasTagFilter(query: string, tag: string): boolean {
|
||||
const normalizedTag = normalizeTag(tag);
|
||||
if (!normalizedTag) {
|
||||
return false;
|
||||
}
|
||||
const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "i");
|
||||
return pattern.test(query.trim());
|
||||
}
|
||||
|
||||
export function appendTagFilter(query: string, tag: string): string {
|
||||
const normalizedTag = normalizeTag(tag);
|
||||
const trimmed = query.trim();
|
||||
if (!normalizedTag) {
|
||||
return trimmed;
|
||||
}
|
||||
if (!trimmed) {
|
||||
return `tag:${normalizedTag}`;
|
||||
}
|
||||
if (hasTagFilter(trimmed, normalizedTag)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed} tag:${normalizedTag}`;
|
||||
}
|
||||
|
||||
export function removeTagFilter(query: string, tag: string): string {
|
||||
const normalizedTag = normalizeTag(tag);
|
||||
const trimmed = query.trim();
|
||||
if (!normalizedTag || !trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "ig");
|
||||
return trimmed.replace(pattern, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function replaceTagFilters(query: string, tags: readonly string[]): string {
|
||||
const uniqueTags: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const tag of tags) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
uniqueTags.push(normalized);
|
||||
}
|
||||
|
||||
const trimmed = query.trim();
|
||||
const withoutTags = trimmed
|
||||
.replace(/(^|\s)tag:([^\s]+)/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const tagTokens = uniqueTags.map((tag) => `tag:${tag}`).join(" ");
|
||||
if (withoutTags && tagTokens) {
|
||||
return `${withoutTags} ${tagTokens}`;
|
||||
}
|
||||
if (withoutTags) {
|
||||
return withoutTags;
|
||||
}
|
||||
return tagTokens;
|
||||
}
|
||||
|
||||
export function toggleTagFilter(query: string, tag: string): string {
|
||||
if (hasTagFilter(query, tag)) {
|
||||
return removeTagFilter(query, tag);
|
||||
}
|
||||
return appendTagFilter(query, tag);
|
||||
}
|
||||
@@ -198,4 +198,35 @@ describe("config view", () => {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onSearchChange).toHaveBeenCalledWith("gateway");
|
||||
});
|
||||
|
||||
it("shows all tag options in compact tag picker", () => {
|
||||
const container = document.createElement("div");
|
||||
render(renderConfig(baseProps()), container);
|
||||
|
||||
const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map(
|
||||
(option) => option.textContent?.trim(),
|
||||
);
|
||||
expect(options).toContain("tag:security");
|
||||
expect(options).toContain("tag:advanced");
|
||||
expect(options).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("updates search query when toggling a tag option", () => {
|
||||
const container = document.createElement("div");
|
||||
const onSearchChange = vi.fn();
|
||||
render(
|
||||
renderConfig({
|
||||
...baseProps(),
|
||||
onSearchChange,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const option = container.querySelector<HTMLButtonElement>(
|
||||
'.config-search__tag-option[data-tag="security"]',
|
||||
);
|
||||
expect(option).toBeTruthy();
|
||||
option?.click();
|
||||
expect(onSearchChange).toHaveBeenCalledWith("tag:security");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
|
||||
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
|
||||
import { getTagFilters, replaceTagFilters } from "./config-search.ts";
|
||||
|
||||
export type ConfigProps = {
|
||||
raw: string;
|
||||
@@ -34,6 +35,24 @@ export type ConfigProps = {
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
const TAG_SEARCH_PRESETS = [
|
||||
"security",
|
||||
"auth",
|
||||
"network",
|
||||
"access",
|
||||
"privacy",
|
||||
"observability",
|
||||
"performance",
|
||||
"reliability",
|
||||
"storage",
|
||||
"models",
|
||||
"media",
|
||||
"automation",
|
||||
"channels",
|
||||
"tools",
|
||||
"advanced",
|
||||
] as const;
|
||||
|
||||
// SVG Icons for sidebar (Lucide-style)
|
||||
const sidebarIcons = {
|
||||
all: html`
|
||||
@@ -443,6 +462,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
hasChanges &&
|
||||
(props.formMode === "raw" ? true : canSaveForm);
|
||||
const canUpdate = props.connected && !props.applying && !props.updating;
|
||||
const selectedTags = new Set(getTagFilters(props.searchQuery));
|
||||
|
||||
return html`
|
||||
<div class="config-layout">
|
||||
@@ -460,35 +480,91 @@ export function renderConfig(props: ConfigProps) {
|
||||
|
||||
<!-- Search -->
|
||||
<div class="config-search">
|
||||
<svg
|
||||
class="config-search__icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="config-search__input"
|
||||
placeholder="Search settings..."
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
${
|
||||
props.searchQuery
|
||||
? html`
|
||||
<button
|
||||
class="config-search__clear"
|
||||
@click=${() => props.onSearchChange("")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="config-search__input-row">
|
||||
<svg
|
||||
class="config-search__icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="config-search__input"
|
||||
placeholder="Search settings..."
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
${
|
||||
props.searchQuery
|
||||
? html`
|
||||
<button
|
||||
class="config-search__clear"
|
||||
@click=${() => props.onSearchChange("")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="config-search__hint">
|
||||
<span class="config-search__hint-label" id="config-tag-filter-label">Tag filters:</span>
|
||||
<details class="config-search__tag-picker">
|
||||
<summary class="config-search__tag-trigger" aria-labelledby="config-tag-filter-label">
|
||||
${
|
||||
selectedTags.size === 0
|
||||
? html`
|
||||
<span class="config-search__tag-placeholder">Add tags</span>
|
||||
`
|
||||
: html`
|
||||
<div class="config-search__tag-chips">
|
||||
${Array.from(selectedTags)
|
||||
.slice(0, 2)
|
||||
.map(
|
||||
(tag) =>
|
||||
html`<span class="config-search__tag-chip">tag:${tag}</span>`,
|
||||
)}
|
||||
${
|
||||
selectedTags.size > 2
|
||||
? html`
|
||||
<span class="config-search__tag-chip config-search__tag-chip--count"
|
||||
>+${selectedTags.size - 2}</span
|
||||
>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<span class="config-search__tag-caret" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="config-search__tag-menu">
|
||||
${TAG_SEARCH_PRESETS.map((tag) => {
|
||||
const active = selectedTags.has(tag);
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class="config-search__tag-option ${active ? "active" : ""}"
|
||||
data-tag="${tag}"
|
||||
aria-pressed=${active ? "true" : "false"}
|
||||
@click=${() => {
|
||||
const nextTags = active
|
||||
? Array.from(selectedTags).filter((value) => value !== tag)
|
||||
: [...selectedTags, tag];
|
||||
props.onSearchChange(replaceTagFilters(props.searchQuery, nextTags));
|
||||
}}
|
||||
>
|
||||
tag:${tag}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section nav -->
|
||||
|
||||
Reference in New Issue
Block a user