From 96318641d8eb450b88c308175b925cb4565b82fd Mon Sep 17 00:00:00 2001 From: Henry Loenwind Date: Fri, 13 Feb 2026 16:19:21 +0100 Subject: [PATCH] fix: Finish credential redaction that was merged unfinished (#13073) * Squash * Removed unused files Not mine, someone merged that stuff in earlier. * fix: patch redaction regressions and schema breakages --------- Co-authored-by: Peter Steinberger --- src/config/config.schema-regressions.test.ts | 39 + src/config/redact-snapshot.test.ts | 534 +++++++++++- src/config/redact-snapshot.ts | 532 +++++++++--- ...chema.field-metadata.ts => schema.help.ts} | 375 +-------- src/config/schema.hints.test.ts | 88 ++ src/config/schema.hints.ts | 780 +++--------------- src/config/schema.labels.ts | 298 +++++++ src/config/schema.test.ts | 15 + src/config/schema.ts | 40 +- src/config/zod-schema.agent-runtime.ts | 9 +- src/config/zod-schema.core.ts | 7 +- src/config/zod-schema.hooks.ts | 5 +- src/config/zod-schema.providers-core.ts | 27 +- src/config/zod-schema.sensitive.ts | 5 + src/config/zod-schema.ts | 15 +- src/gateway/server-methods/config.ts | 148 ++-- ui/src/ui/views/config-form.node.ts | 4 +- ui/src/ui/views/config-form.shared.ts | 11 - 18 files changed, 1641 insertions(+), 1291 deletions(-) create mode 100644 src/config/config.schema-regressions.test.ts rename src/config/{schema.field-metadata.ts => schema.help.ts} (56%) create mode 100644 src/config/schema.hints.test.ts create mode 100644 src/config/schema.labels.ts create mode 100644 src/config/zod-schema.sensitive.ts diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts new file mode 100644 index 0000000000..ffe204bc68 --- /dev/null +++ b/src/config/config.schema-regressions.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("config schema regressions", () => { + it("accepts nested telegram groupPolicy overrides", () => { + const res = validateConfigObject({ + channels: { + telegram: { + groups: { + "-1001234567890": { + groupPolicy: "open", + topics: { + "42": { + groupPolicy: "disabled", + }, + }, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it('accepts memorySearch fallback "voyage"', () => { + const res = validateConfigObject({ + agents: { + defaults: { + memorySearch: { + fallback: "voyage", + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 4590272a47..dbf7e07fe0 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from "vitest"; +import type { ConfigUiHints } from "./schema.js"; import type { ConfigFileSnapshot } from "./types.openclaw.js"; import { REDACTED_SENTINEL, redactConfigSnapshot, - restoreRedactedValues, + restoreRedactedValues as restoreRedactedValues_orig, } from "./redact-snapshot.js"; +import { __test__ } from "./schema.hints.js"; +import { OpenClawSchema } from "./zod-schema.js"; + +const { mapSensitivePaths } = __test__; function makeSnapshot(config: Record, raw?: string): ConfigFileSnapshot { return { @@ -22,6 +27,16 @@ function makeSnapshot(config: Record, raw?: string): ConfigFile }; } +function restoreRedactedValues( + incoming: unknown, + original: unknown, + hints?: ConfigUiHints, +): unknown { + var result = restoreRedactedValues_orig(incoming, original, hints); + expect(result.ok).toBe(true); + return result.result; +} + describe("redactConfigSnapshot", () => { it("redacts top-level token fields", () => { const snapshot = makeSnapshot({ @@ -217,6 +232,25 @@ describe("redactConfigSnapshot", () => { expect(result.parsed).toBeNull(); }); + it("withholds resolved config for invalid snapshots", () => { + const snapshot: ConfigFileSnapshot = { + path: "/test", + exists: true, + raw: '{ "gateway": { "auth": { "token": "leaky-secret" } } }', + parsed: { gateway: { auth: { token: "leaky-secret" } } }, + resolved: { gateway: { auth: { token: "leaky-secret" } } } as ConfigFileSnapshot["resolved"], + valid: false, + config: {} as ConfigFileSnapshot["config"], + issues: [{ path: "", message: "invalid config" }], + warnings: [], + legacyIssues: [], + }; + const result = redactConfigSnapshot(snapshot); + expect(result.raw).toBeNull(); + expect(result.parsed).toBeNull(); + expect(result.resolved).toEqual({}); + }); + it("handles deeply nested tokens in accounts", () => { const snapshot = makeSnapshot({ channels: { @@ -259,35 +293,379 @@ describe("redactConfigSnapshot", () => { }); const result = redactConfigSnapshot(snapshot); const env = result.config.env as Record>; - expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL); // NODE_ENV is not sensitive, should be preserved expect(env.vars.NODE_ENV).toBe("production"); + expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL); }); - it("redacts raw by key pattern even when parsed config is empty", () => { - const snapshot: ConfigFileSnapshot = { - path: "/test", - exists: true, - raw: '{ token: "raw-secret-1234567890" }', - parsed: {}, - valid: false, - config: {} as ConfigFileSnapshot["config"], - issues: [], - warnings: [], - legacyIssues: [], - }; - const result = redactConfigSnapshot(snapshot); - expect(result.raw).not.toContain("raw-secret-1234567890"); - expect(result.raw).toContain(REDACTED_SENTINEL); - }); - - it("redacts sensitive fields even when the value is not a string", () => { + it("does NOT redact numeric 'tokens' fields (token regex fix)", () => { const snapshot = makeSnapshot({ - gateway: { auth: { token: 1234 } }, + memory: { tokens: 8192 }, }); const result = redactConfigSnapshot(snapshot); + const memory = result.config.memory as Record; + expect(memory.tokens).toBe(8192); + }); + + it("does NOT redact 'softThresholdTokens' (token regex fix)", () => { + const snapshot = makeSnapshot({ + compaction: { softThresholdTokens: 50000 }, + }); + const result = redactConfigSnapshot(snapshot); + const compaction = result.config.compaction as Record; + expect(compaction.softThresholdTokens).toBe(50000); + }); + + it("does NOT redact string 'tokens' field either", () => { + const snapshot = makeSnapshot({ + memory: { tokens: "should-not-be-redacted" }, + }); + const result = redactConfigSnapshot(snapshot); + const memory = result.config.memory as Record; + expect(memory.tokens).toBe("should-not-be-redacted"); + }); + + it("still redacts 'token' (singular) fields", () => { + const snapshot = makeSnapshot({ + channels: { slack: { token: "secret-slack-token-value-here" } }, + }); + const result = redactConfigSnapshot(snapshot); + const channels = result.config.channels as Record>; + expect(channels.slack.token).toBe(REDACTED_SENTINEL); + }); + + it("uses uiHints to determine sensitivity", () => { + const hints: ConfigUiHints = { + "custom.mySecret": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + custom: { mySecret: "this-is-a-custom-secret-value" }, + }); + const result = redactConfigSnapshot(snapshot, hints); + const custom = result.config.custom as Record; + expect(custom.mySecret).toBe(REDACTED_SENTINEL); + }); + + it("keeps regex fallback for extension keys not covered by uiHints", () => { + const hints: ConfigUiHints = { + "plugins.entries.voice-call.config": { label: "Voice Call Config" }, + "channels.my-channel": { label: "My Channel" }, + }; + const snapshot = makeSnapshot({ + plugins: { + entries: { + "voice-call": { + config: { + apiToken: "voice-call-secret-token", + displayName: "Voice call extension", + }, + }, + }, + }, + channels: { + "my-channel": { + accessToken: "my-channel-secret-token", + room: "general", + }, + }, + }); + + const redacted = redactConfigSnapshot(snapshot, hints); + expect(redacted.config.plugins.entries["voice-call"].config.apiToken).toBe(REDACTED_SENTINEL); + expect(redacted.config.plugins.entries["voice-call"].config.displayName).toBe( + "Voice call extension", + ); + expect(redacted.config.channels["my-channel"].accessToken).toBe(REDACTED_SENTINEL); + expect(redacted.config.channels["my-channel"].room).toBe("general"); + + const restored = restoreRedactedValues(redacted.config, snapshot.config, hints); + expect(restored).toEqual(snapshot.config); + }); + + it("honors sensitive:false for extension keys even with regex fallback", () => { + const hints: ConfigUiHints = { + "plugins.entries.voice-call.config": { label: "Voice Call Config" }, + "plugins.entries.voice-call.config.apiToken": { sensitive: false }, + }; + const snapshot = makeSnapshot({ + plugins: { + entries: { + "voice-call": { + config: { + apiToken: "not-secret-on-purpose", + }, + }, + }, + }, + }); + + const redacted = redactConfigSnapshot(snapshot, hints); + expect(redacted.config.plugins.entries["voice-call"].config.apiToken).toBe( + "not-secret-on-purpose", + ); + }); + + it("handles nested values properly (roundtrip)", () => { + const snapshot = makeSnapshot({ + custom1: { anykey: { mySecret: "this-is-a-custom-secret-value" } }, + custom2: [{ mySecret: "this-is-a-custom-secret-value" }], + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config.custom1.anykey.mySecret).toBe(REDACTED_SENTINEL); + expect(result.config.custom2[0].mySecret).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config); + expect(restored.custom1.anykey.mySecret).toBe("this-is-a-custom-secret-value"); + expect(restored.custom2[0].mySecret).toBe("this-is-a-custom-secret-value"); + }); + + it("handles nested values properly with hints (roundtrip)", () => { + const hints: ConfigUiHints = { + "custom1.*.mySecret": { sensitive: true }, + "custom2[].mySecret": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + custom1: { anykey: { mySecret: "this-is-a-custom-secret-value" } }, + custom2: [{ mySecret: "this-is-a-custom-secret-value" }], + }); + const result = redactConfigSnapshot(snapshot, hints); + expect(result.config.custom1.anykey.mySecret).toBe(REDACTED_SENTINEL); + expect(result.config.custom2[0].mySecret).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.custom1.anykey.mySecret).toBe("this-is-a-custom-secret-value"); + expect(restored.custom2[0].mySecret).toBe("this-is-a-custom-secret-value"); + }); + + it("handles records that are directly sensitive (roundtrip)", () => { + const snapshot = makeSnapshot({ + custom: { token: "this-is-a-custom-secret-value", mySecret: "this-is-a-custom-secret-value" }, + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config.custom.token).toBe(REDACTED_SENTINEL); + expect(result.config.custom.mySecret).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config); + expect(restored.custom.token).toBe("this-is-a-custom-secret-value"); + expect(restored.custom.mySecret).toBe("this-is-a-custom-secret-value"); + }); + + it("handles records that are directly sensitive with hints (roundtrip)", () => { + const hints: ConfigUiHints = { + "custom.*": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + custom: { + anykey: "this-is-a-custom-secret-value", + mySecret: "this-is-a-custom-secret-value", + }, + }); + const result = redactConfigSnapshot(snapshot, hints); + expect(result.config.custom.anykey).toBe(REDACTED_SENTINEL); + expect(result.config.custom.mySecret).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.custom.anykey).toBe("this-is-a-custom-secret-value"); + expect(restored.custom.mySecret).toBe("this-is-a-custom-secret-value"); + }); + + it("handles arrays that are directly sensitive (roundtrip)", () => { + const snapshot = makeSnapshot({ + token: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"], + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config.token[0]).toBe(REDACTED_SENTINEL); + expect(result.config.token[1]).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config); + expect(restored.token[0]).toBe("this-is-a-custom-secret-value"); + expect(restored.token[1]).toBe("this-is-a-custom-secret-value"); + }); + + it("handles arrays that are directly sensitive with hints (roundtrip)", () => { + const hints: ConfigUiHints = { + "custom[]": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + custom: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"], + }); + const result = redactConfigSnapshot(snapshot, hints); + expect(result.config.custom[0]).toBe(REDACTED_SENTINEL); + expect(result.config.custom[1]).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.custom[0]).toBe("this-is-a-custom-secret-value"); + expect(restored.custom[1]).toBe("this-is-a-custom-secret-value"); + }); + + it("handles arrays that are not sensitive (roundtrip)", () => { + const snapshot = makeSnapshot({ + harmless: ["this-is-a-custom-harmless-value", "this-is-a-custom-secret-looking-value"], + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config.harmless[0]).toBe("this-is-a-custom-harmless-value"); + expect(result.config.harmless[1]).toBe("this-is-a-custom-secret-looking-value"); + const restored = restoreRedactedValues(result.config, snapshot.config); + expect(restored.harmless[0]).toBe("this-is-a-custom-harmless-value"); + expect(restored.harmless[1]).toBe("this-is-a-custom-secret-looking-value"); + }); + + it("handles arrays that are not sensitive with hints (roundtrip)", () => { + const hints: ConfigUiHints = { + "custom[]": { sensitive: false }, + }; + const snapshot = makeSnapshot({ + custom: ["this-is-a-custom-harmless-value", "this-is-a-custom-secret-value"], + }); + const result = redactConfigSnapshot(snapshot, hints); + expect(result.config.custom[0]).toBe("this-is-a-custom-harmless-value"); + expect(result.config.custom[1]).toBe("this-is-a-custom-secret-value"); + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.custom[0]).toBe("this-is-a-custom-harmless-value"); + expect(restored.custom[1]).toBe("this-is-a-custom-secret-value"); + }); + + it("handles deep arrays that are directly sensitive (roundtrip)", () => { + const snapshot = makeSnapshot({ + nested: { + level: { + token: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"], + }, + }, + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config.nested.level.token[0]).toBe(REDACTED_SENTINEL); + expect(result.config.nested.level.token[1]).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config); + expect(restored.nested.level.token[0]).toBe("this-is-a-custom-secret-value"); + expect(restored.nested.level.token[1]).toBe("this-is-a-custom-secret-value"); + }); + + it("handles deep arrays that are directly sensitive with hints (roundtrip)", () => { + const hints: ConfigUiHints = { + "nested.level.custom[]": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + nested: { + level: { + custom: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"], + }, + }, + }); + const result = redactConfigSnapshot(snapshot, hints); + expect(result.config.nested.level.custom[0]).toBe(REDACTED_SENTINEL); + expect(result.config.nested.level.custom[1]).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.nested.level.custom[0]).toBe("this-is-a-custom-secret-value"); + expect(restored.nested.level.custom[1]).toBe("this-is-a-custom-secret-value"); + }); + + it("handles deep non-string arrays that are directly sensitive (roundtrip)", () => { + const snapshot = makeSnapshot({ + nested: { + level: { + token: [42, 815], + }, + }, + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config.nested.level.token[0]).toBe(42); + expect(result.config.nested.level.token[1]).toBe(815); + const restored = restoreRedactedValues(result.config, snapshot.config); + expect(restored.nested.level.token[0]).toBe(42); + expect(restored.nested.level.token[1]).toBe(815); + }); + + it("handles deep non-string arrays that are directly sensitive with hints (roundtrip)", () => { + const hints: ConfigUiHints = { + "nested.level.custom[]": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + nested: { + level: { + custom: [42, 815], + }, + }, + }); + const result = redactConfigSnapshot(snapshot, hints); + expect(result.config.nested.level.custom[0]).toBe(42); + expect(result.config.nested.level.custom[1]).toBe(815); + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.nested.level.custom[0]).toBe(42); + expect(restored.nested.level.custom[1]).toBe(815); + }); + + it("handles deep arrays that are upstream sensitive (roundtrip)", () => { + const snapshot = makeSnapshot({ + nested: { + password: { + harmless: ["value", "value"], + }, + }, + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config.nested.password.harmless[0]).toBe(REDACTED_SENTINEL); + expect(result.config.nested.password.harmless[1]).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config); + expect(restored.nested.password.harmless[0]).toBe("value"); + expect(restored.nested.password.harmless[1]).toBe("value"); + }); + + it("handles deep arrays that are not sensitive (roundtrip)", () => { + const snapshot = makeSnapshot({ + nested: { + level: { + harmless: ["value", "value"], + }, + }, + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config.nested.level.harmless[0]).toBe("value"); + expect(result.config.nested.level.harmless[1]).toBe("value"); + const restored = restoreRedactedValues(result.config, snapshot.config); + expect(restored.nested.level.harmless[0]).toBe("value"); + expect(restored.nested.level.harmless[1]).toBe("value"); + }); + + it("respects sensitive:false in uiHints even for regex-matching paths", () => { + const hints: ConfigUiHints = { + "gateway.auth.token": { sensitive: false }, + }; + const snapshot = makeSnapshot({ + gateway: { auth: { token: "not-actually-secret-value" } }, + }); + const result = redactConfigSnapshot(snapshot, hints); const gw = result.config.gateway as Record>; - expect(gw.auth.token).toBe(REDACTED_SENTINEL); + expect(gw.auth.token).toBe("not-actually-secret-value"); + }); + + it("does not redact paths absent from uiHints (schema is single source of truth)", () => { + const hints: ConfigUiHints = { + "some.other.path": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + gateway: { auth: { password: "not-in-hints-value" } }, + }); + const result = redactConfigSnapshot(snapshot, hints); + const gw = result.config.gateway as Record>; + expect(gw.auth.password).toBe("not-in-hints-value"); + }); + + it("uses wildcard hints for array items", () => { + const hints: ConfigUiHints = { + "channels.slack.accounts[].botToken": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + channels: { + slack: { + accounts: [ + { botToken: "first-account-token-value-here" }, + { botToken: "second-account-token-value-here" }, + ], + }, + }, + }); + const result = redactConfigSnapshot(snapshot, hints); + const channels = result.config.channels as Record< + string, + Record>> + >; + expect(channels.slack.accounts[0].botToken).toBe(REDACTED_SENTINEL); + expect(channels.slack.accounts[1].botToken).toBe(REDACTED_SENTINEL); }); }); @@ -360,12 +738,12 @@ describe("restoreRedactedValues", () => { channels: { newChannel: { token: REDACTED_SENTINEL } }, }; const original = {}; - expect(() => restoreRedactedValues(incoming, original)).toThrow(/redacted/i); + expect(restoreRedactedValues_orig(incoming, original).ok).toBe(false); }); it("handles null and undefined inputs", () => { - expect(restoreRedactedValues(null, { token: "x" })).toBeNull(); - expect(restoreRedactedValues(undefined, { token: "x" })).toBeUndefined(); + expect(restoreRedactedValues_orig(null, { token: "x" }).ok).toBe(false); + expect(restoreRedactedValues_orig(undefined, { token: "x" }).ok).toBe(false); }); it("round-trips config through redact → restore", () => { @@ -398,4 +776,110 @@ describe("restoreRedactedValues", () => { expect(restored).toEqual(originalConfig); }); + + it("round-trips with uiHints for custom sensitive fields", () => { + const hints: ConfigUiHints = { + "custom.myApiKey": { sensitive: true }, + "custom.displayName": { sensitive: false }, + }; + const originalConfig = { + custom: { myApiKey: "secret-custom-api-key-value", displayName: "My Bot" }, + }; + const snapshot = makeSnapshot(originalConfig); + const redacted = redactConfigSnapshot(snapshot, hints); + const custom = redacted.config.custom as Record; + expect(custom.myApiKey).toBe(REDACTED_SENTINEL); + expect(custom.displayName).toBe("My Bot"); + + const restored = restoreRedactedValues( + redacted.config, + snapshot.config, + hints, + ) as typeof originalConfig; + expect(restored).toEqual(originalConfig); + }); + + it("restores with uiHints respecting sensitive:false override", () => { + const hints: ConfigUiHints = { + "gateway.auth.token": { sensitive: false }, + }; + const incoming = { + gateway: { auth: { token: REDACTED_SENTINEL } }, + }; + const original = { + gateway: { auth: { token: "real-secret" } }, + }; + // With sensitive:false, the sentinel is NOT on a sensitive path, + // so restore should NOT replace it (it's treated as a literal value) + const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; + expect(result.gateway.auth.token).toBe(REDACTED_SENTINEL); + }); + + it("restores array items using wildcard uiHints", () => { + const hints: ConfigUiHints = { + "channels.slack.accounts[].botToken": { sensitive: true }, + }; + const incoming = { + channels: { + slack: { + accounts: [ + { botToken: REDACTED_SENTINEL }, + { botToken: "user-provided-new-token-value" }, + ], + }, + }, + }; + const original = { + channels: { + slack: { + accounts: [ + { botToken: "original-token-first-account" }, + { botToken: "original-token-second-account" }, + ], + }, + }, + }; + const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; + expect(result.channels.slack.accounts[0].botToken).toBe("original-token-first-account"); + expect(result.channels.slack.accounts[1].botToken).toBe("user-provided-new-token-value"); + }); +}); + +describe("realredactConfigSnapshot_real", () => { + it("main schema redact works (samples)", () => { + const schema = OpenClawSchema.toJSONSchema({ + target: "draft-07", + unrepresentable: "any", + }); + schema.title = "OpenClawConfig"; + const hints = mapSensitivePaths(OpenClawSchema, "", {}); + + const snapshot = makeSnapshot({ + agents: { + defaults: { + memorySearch: { + remote: { + apiKey: "1234", + }, + }, + }, + list: [ + { + memorySearch: { + remote: { + apiKey: "6789", + }, + }, + }, + ], + }, + }); + + const result = redactConfigSnapshot(snapshot, hints); + expect(result.config.agents.defaults.memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL); + expect(result.config.agents.list[0].memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.agents.defaults.memorySearch.remote.apiKey).toBe("1234"); + expect(restored.agents.list[0].memorySearch.remote.apiKey).toBe("6789"); + }); }); diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 378f6ec0c9..eaf6ed3ee9 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -1,4 +1,37 @@ import type { ConfigFileSnapshot } from "./types.openclaw.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { isSensitiveConfigPath, type ConfigUiHints } from "./schema.hints.js"; + +const log = createSubsystemLogger("config/redaction"); +const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/; + +function isSensitivePath(path: string): boolean { + if (path.endsWith("[]")) { + return isSensitiveConfigPath(path.slice(0, -2)); + } else { + return isSensitiveConfigPath(path); + } +} + +function isEnvVarPlaceholder(value: string): boolean { + return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim()); +} + +function isExtensionPath(path: string): boolean { + return ( + path === "plugins" || + path.startsWith("plugins.") || + path === "channels" || + path.startsWith("channels.") + ); +} + +function isExplicitlyNonSensitivePath(hints: ConfigUiHints | undefined, paths: string[]): boolean { + if (!hints) { + return false; + } + return paths.some((path) => hints[path]?.sensitive === false); +} /** * Sentinel value used to replace sensitive config fields in gateway responses. @@ -8,120 +41,216 @@ import type { ConfigFileSnapshot } from "./types.openclaw.js"; */ export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__"; -/** - * Non-sensitive field names that happen to match sensitive patterns. - * These are explicitly excluded from redaction. - */ -const SENSITIVE_KEY_WHITELIST = new Set([ - "maxtokens", - "maxoutputtokens", - "maxinputtokens", - "maxcompletiontokens", - "contexttokens", - "totaltokens", - "tokencount", - "tokenlimit", - "tokenbudget", -]); +// ConfigUiHints' keys look like this: +// - path.subpath.key (nested objects) +// - path.subpath[].key (object in array in object) +// - path.*.key (object in record in object) +// records are handled by the lookup, but arrays need two entries in +// the Set, as their first lookup is done before the code knows it's +// an array. +function buildRedactionLookup(hints: ConfigUiHints): Set { + let result = new Set(); -/** - * Patterns that identify sensitive config field names. - * Aligned with the UI-hint logic in schema.ts. - */ -const SENSITIVE_KEY_PATTERNS = [/token$/i, /password/i, /secret/i, /api.?key/i]; - -function isSensitiveKey(key: string): boolean { - if (SENSITIVE_KEY_WHITELIST.has(key.toLowerCase())) { - return false; - } - return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); -} - -/** - * Deep-walk an object and replace values whose key matches a sensitive pattern - * with the redaction sentinel. - */ -function redactObject(obj: unknown): unknown { - if (obj === null || obj === undefined) { - return obj; - } - if (typeof obj !== "object") { - return obj; - } - if (Array.isArray(obj)) { - return obj.map(redactObject); - } - const result: Record = {}; - for (const [key, value] of Object.entries(obj as Record)) { - if (isSensitiveKey(key) && value !== null && value !== undefined) { - result[key] = REDACTED_SENTINEL; - } else if (typeof value === "object" && value !== null) { - result[key] = redactObject(value); - } else { - result[key] = value; + for (const [path, hint] of Object.entries(hints)) { + if (!hint.sensitive) { + continue; } + + const parts = path.split("."); + let joinedPath = parts.shift() ?? ""; + result.add(joinedPath); + if (joinedPath.endsWith("[]")) { + result.add(joinedPath.slice(0, -2)); + } + + for (const part of parts) { + if (part.endsWith("[]")) { + result.add(`${joinedPath}.${part.slice(0, -2)}`); + } + // hey, greptile, notice how this is *NOT* in an else block? + joinedPath = `${joinedPath}.${part}`; + result.add(joinedPath); + } + } + if (result.size !== 0) { + result.add(""); } return result; } -export function redactConfigObject(value: T): T { - return redactObject(value) as T; +/** + * Deep-walk an object and replace string values at sensitive paths + * with the redaction sentinel. + */ +function redactObject(obj: unknown, hints?: ConfigUiHints): unknown { + if (hints) { + const lookup = buildRedactionLookup(hints); + return lookup.has("") + ? redactObjectWithLookup(obj, lookup, "", [], hints) + : redactObjectGuessing(obj, "", [], hints); + } else { + return redactObjectGuessing(obj, "", []); + } } /** * Collect all sensitive string values from a config object. * Used for text-based redaction of the raw JSON5 source. */ -function collectSensitiveValues(obj: unknown): string[] { - const values: string[] = []; - if (obj === null || obj === undefined || typeof obj !== "object") { - return values; +function collectSensitiveValues(obj: unknown, hints?: ConfigUiHints): string[] { + const result: string[] = []; + if (hints) { + const lookup = buildRedactionLookup(hints); + if (lookup.has("")) { + redactObjectWithLookup(obj, lookup, "", result, hints); + } else { + redactObjectGuessing(obj, "", result, hints); + } + } else { + redactObjectGuessing(obj, "", result); } + return result; +} + +/** + * Worker for redactObject() and collectSensitiveValues(). + * Used when there are ConfigUiHints available. + */ +function redactObjectWithLookup( + obj: unknown, + lookup: Set, + prefix: string, + values: string[], + hints: ConfigUiHints, +): unknown { + if (obj === null || obj === undefined) { + return obj; + } + if (Array.isArray(obj)) { - for (const item of obj) { - values.push(...collectSensitiveValues(item)); + const path = `${prefix}[]`; + if (!lookup.has(path)) { + if (!isExtensionPath(prefix)) { + return obj; + } + return redactObjectGuessing(obj, prefix, values, hints); } - return values; + return obj.map((item) => { + if (typeof item === "string" && !isEnvVarPlaceholder(item)) { + values.push(item); + return REDACTED_SENTINEL; + } + return redactObjectWithLookup(item, lookup, path, values, hints); + }); } - for (const [key, value] of Object.entries(obj as Record)) { - if (isSensitiveKey(key) && typeof value === "string" && value.length > 0) { - values.push(value); - } else if (typeof value === "object" && value !== null) { - values.push(...collectSensitiveValues(value)); + + if (typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + const wildcardPath = prefix ? `${prefix}.*` : "*"; + let matched = false; + for (const candidate of [path, wildcardPath]) { + result[key] = value; + if (lookup.has(candidate)) { + matched = true; + // Hey, greptile, look here, this **IS** only applied to strings + if (typeof value === "string" && !isEnvVarPlaceholder(value)) { + result[key] = REDACTED_SENTINEL; + values.push(value); + } else if (typeof value === "object" && value !== null) { + result[key] = redactObjectWithLookup(value, lookup, candidate, values, hints); + } + break; + } + } + if (!matched && isExtensionPath(path)) { + const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]); + if ( + typeof value === "string" && + !markedNonSensitive && + isSensitivePath(path) && + !isEnvVarPlaceholder(value) + ) { + result[key] = REDACTED_SENTINEL; + values.push(value); + } else if (typeof value === "object" && value !== null) { + result[key] = redactObjectGuessing(value, path, values, hints); + } + } } + return result; } - return values; + + return obj; +} + +/** + * Worker for redactObject() and collectSensitiveValues(). + * Used when ConfigUiHints are NOT available. + */ +function redactObjectGuessing( + obj: unknown, + prefix: string, + values: string[], + hints?: ConfigUiHints, +): unknown { + if (obj === null || obj === undefined) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => { + const path = `${prefix}[]`; + if ( + !isExplicitlyNonSensitivePath(hints, [path]) && + isSensitivePath(path) && + typeof item === "string" && + !isEnvVarPlaceholder(item) + ) { + values.push(item); + return REDACTED_SENTINEL; + } + return redactObjectGuessing(item, path, values, hints); + }); + } + + if (typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + const dotPath = prefix ? `${prefix}.${key}` : key; + const wildcardPath = prefix ? `${prefix}.*` : "*"; + if ( + !isExplicitlyNonSensitivePath(hints, [dotPath, wildcardPath]) && + isSensitivePath(dotPath) && + typeof value === "string" && + !isEnvVarPlaceholder(value) + ) { + result[key] = REDACTED_SENTINEL; + values.push(value); + } else if (typeof value === "object" && value !== null) { + result[key] = redactObjectGuessing(value, dotPath, values, hints); + } else { + result[key] = value; + } + } + return result; + } + + return obj; } /** * Replace known sensitive values in a raw JSON5 string with the sentinel. * Values are replaced longest-first to avoid partial matches. */ -function redactRawText(raw: string, config: unknown): string { - const sensitiveValues = collectSensitiveValues(config); +function redactRawText(raw: string, config: unknown, hints?: ConfigUiHints): string { + const sensitiveValues = collectSensitiveValues(config, hints); sensitiveValues.sort((a, b) => b.length - a.length); let result = raw; for (const value of sensitiveValues) { - const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - result = result.replace(new RegExp(escaped, "g"), REDACTED_SENTINEL); + result = result.replaceAll(value, REDACTED_SENTINEL); } - - const keyValuePattern = - /(^|[{\s,])((["'])([^"']+)\3|([A-Za-z0-9_$.-]+))(\s*:\s*)(["'])([^"']*)\7/g; - result = result.replace( - keyValuePattern, - (match, prefix, keyExpr, _keyQuote, keyQuoted, keyBare, sep, valQuote, val) => { - const key = (keyQuoted ?? keyBare) as string | undefined; - if (!key || !isSensitiveKey(key)) { - return match; - } - if (val === REDACTED_SENTINEL) { - return match; - } - return `${prefix}${keyExpr}${sep}${valQuote}${REDACTED_SENTINEL}${valQuote}`; - }, - ); - return result; } @@ -132,11 +261,45 @@ function redactRawText(raw: string, config: unknown): string { * * Both `config` (the parsed object) and `raw` (the JSON5 source) are scrubbed * so no credential can leak through either path. + * + * When `uiHints` are provided, sensitivity is determined from the schema hints. + * Without hints, falls back to regex-based detection via `isSensitivePath()`. */ -export function redactConfigSnapshot(snapshot: ConfigFileSnapshot): ConfigFileSnapshot { - const redactedConfig = redactConfigObject(snapshot.config); - const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config) : null; - const redactedParsed = snapshot.parsed ? redactConfigObject(snapshot.parsed) : snapshot.parsed; +/** + * Redact sensitive fields from a plain config object (not a full snapshot). + * Used by write endpoints (config.set, config.patch, config.apply) to avoid + * leaking credentials in their responses. + */ +export function redactConfigObject(value: T, uiHints?: ConfigUiHints): T { + return redactObject(value, uiHints) as T; +} + +export function redactConfigSnapshot( + snapshot: ConfigFileSnapshot, + uiHints?: ConfigUiHints, +): ConfigFileSnapshot { + if (!snapshot.valid) { + // This is bad. We could try to redact the raw string using known key names, + // but then we would not be able to restore them, and would trash the user's + // credentials. Less than ideal---we should never delete important data. + // On the other hand, we cannot hand out "raw" if we're not sure we have + // properly redacted all sensitive data. Handing out a partially or, worse, + // unredacted config string would be bad. + // Therefore, the only safe route is to reject handling out broken configs. + return { + ...snapshot, + config: {}, + raw: null, + parsed: null, + resolved: {}, + }; + } + // else: snapshot.config must be valid and populated, as that is what + // readConfigFileSnapshot() does when it creates the snapshot. + + const redactedConfig = redactObject(snapshot.config, uiHints) as ConfigFileSnapshot["config"]; + const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config, uiHints) : null; + const redactedParsed = snapshot.parsed ? redactObject(snapshot.parsed, uiHints) : snapshot.parsed; // Also redact the resolved config (contains values after ${ENV} substitution) const redactedResolved = redactConfigObject(snapshot.resolved); @@ -149,14 +312,78 @@ export function redactConfigSnapshot(snapshot: ConfigFileSnapshot): ConfigFileSn }; } +export type RedactionResult = { + ok: boolean; + result?: unknown; + error?: unknown; + humanReadableMessage?: string; +}; + /** * Deep-walk `incoming` and replace any {@link REDACTED_SENTINEL} values - * (on sensitive keys) with the corresponding value from `original`. + * (on sensitive paths) with the corresponding value from `original`. * * This is called by config.set / config.apply / config.patch before writing, * so that credentials survive a Web UI round-trip unmodified. */ -export function restoreRedactedValues(incoming: unknown, original: unknown): unknown { +export function restoreRedactedValues( + incoming: unknown, + original: unknown, + hints?: ConfigUiHints, +): RedactionResult { + if (incoming === null || incoming === undefined) { + return { ok: false, error: "no input" }; + } + if (typeof incoming !== "object") { + return { ok: false, error: "input not an object" }; + } + try { + if (hints) { + const lookup = buildRedactionLookup(hints); + if (lookup.has("")) { + return { + ok: true, + result: restoreRedactedValuesWithLookup(incoming, original, lookup, "", hints), + }; + } else { + return { ok: true, result: restoreRedactedValuesGuessing(incoming, original, "", hints) }; + } + } else { + return { ok: true, result: restoreRedactedValuesGuessing(incoming, original, "") }; + } + } catch (err) { + if (err instanceof RedactionError) { + return { + ok: false, + humanReadableMessage: `Sentinel value "${REDACTED_SENTINEL}" in key ${err.key} is not valid as real data`, + }; + } + throw err; // some coding error, pass through + } +} + +class RedactionError extends Error { + public readonly key: string; + + constructor(key: string) { + super("internal error class---should never escape"); + this.key = key; + this.name = "RedactionError"; + Object.setPrototypeOf(this, RedactionError.prototype); + } +} + +/** + * Worker for restoreRedactedValues(). + * Used when there are ConfigUiHints available. + */ +function restoreRedactedValuesWithLookup( + incoming: unknown, + original: unknown, + lookup: Set, + prefix: string, + hints: ConfigUiHints, +): unknown { if (incoming === null || incoming === undefined) { return incoming; } @@ -164,8 +391,27 @@ export function restoreRedactedValues(incoming: unknown, original: unknown): unk return incoming; } if (Array.isArray(incoming)) { + // Note: If the user removed an item in the middle of the array, + // we have no way of knowing which one. In this case, the last + // element(s) get(s) chopped off. Not good, so please don't put + // sensitive string array in the config... + const path = `${prefix}[]`; + if (!lookup.has(path)) { + if (!isExtensionPath(prefix)) { + return incoming; + } + return restoreRedactedValuesGuessing(incoming, original, prefix, hints); + } const origArr = Array.isArray(original) ? original : []; - return incoming.map((item, i) => restoreRedactedValues(item, origArr[i])); + if (incoming.length < origArr.length) { + log.warn(`Redacted config array key ${path} has been truncated`); + } + return incoming.map((item, i) => { + if (item === REDACTED_SENTINEL) { + return origArr[i]; + } + return restoreRedactedValuesWithLookup(item, origArr[i], lookup, path, hints); + }); } const orig = original && typeof original === "object" && !Array.isArray(original) @@ -173,15 +419,101 @@ export function restoreRedactedValues(incoming: unknown, original: unknown): unk : {}; const result: Record = {}; for (const [key, value] of Object.entries(incoming as Record)) { - if (isSensitiveKey(key) && value === REDACTED_SENTINEL) { - if (!(key in orig)) { - throw new Error( - `config write rejected: "${key}" is redacted; set an explicit value instead of ${REDACTED_SENTINEL}`, - ); + result[key] = value; + const path = prefix ? `${prefix}.${key}` : key; + const wildcardPath = prefix ? `${prefix}.*` : "*"; + let matched = false; + for (const candidate of [path, wildcardPath]) { + if (lookup.has(candidate)) { + matched = true; + if (value === REDACTED_SENTINEL) { + if (key in orig) { + result[key] = orig[key]; + } else { + log.warn(`Cannot un-redact config key ${candidate} as it doesn't have any value`); + throw new RedactionError(candidate); + } + } else if (typeof value === "object" && value !== null) { + result[key] = restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints); + } + break; + } + } + if (!matched && isExtensionPath(path)) { + const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]); + if (!markedNonSensitive && isSensitivePath(path) && value === REDACTED_SENTINEL) { + if (key in orig) { + result[key] = orig[key]; + } else { + log.warn(`Cannot un-redact config key ${path} as it doesn't have any value`); + throw new RedactionError(path); + } + } else if (typeof value === "object" && value !== null) { + result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints); + } + } + } + return result; +} + +/** + * Worker for restoreRedactedValues(). + * Used when ConfigUiHints are NOT available. + */ +function restoreRedactedValuesGuessing( + incoming: unknown, + original: unknown, + prefix: string, + hints?: ConfigUiHints, +): unknown { + if (incoming === null || incoming === undefined) { + return incoming; + } + if (typeof incoming !== "object") { + return incoming; + } + if (Array.isArray(incoming)) { + // Note: If the user removed an item in the middle of the array, + // we have no way of knowing which one. In this case, the last + // element(s) get(s) chopped off. Not good, so please don't put + // sensitive string array in the config... + const origArr = Array.isArray(original) ? original : []; + return incoming.map((item, i) => { + const path = `${prefix}[]`; + if (incoming.length < origArr.length) { + log.warn(`Redacted config array key ${path} has been truncated`); + } + if ( + !isExplicitlyNonSensitivePath(hints, [path]) && + isSensitivePath(path) && + item === REDACTED_SENTINEL + ) { + return origArr[i]; + } + return restoreRedactedValuesGuessing(item, origArr[i], path, hints); + }); + } + const orig = + original && typeof original === "object" && !Array.isArray(original) + ? (original as Record) + : {}; + const result: Record = {}; + for (const [key, value] of Object.entries(incoming as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + const wildcardPath = prefix ? `${prefix}.*` : "*"; + if ( + !isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) && + isSensitivePath(path) && + value === REDACTED_SENTINEL + ) { + if (key in orig) { + result[key] = orig[key]; + } else { + log.warn(`Cannot un-redact config key ${path} as it doesn't have any value`); + throw new RedactionError(path); } - result[key] = orig[key]; } else if (typeof value === "object" && value !== null) { - result[key] = restoreRedactedValues(value, orig[key]); + result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints); } else { result[key] = value; } diff --git a/src/config/schema.field-metadata.ts b/src/config/schema.help.ts similarity index 56% rename from src/config/schema.field-metadata.ts rename to src/config/schema.help.ts index e1644fa01d..222cd7f454 100644 --- a/src/config/schema.field-metadata.ts +++ b/src/config/schema.help.ts @@ -1,355 +1,4 @@ -export const GROUP_LABELS: Record = { - wizard: "Wizard", - update: "Update", - diagnostics: "Diagnostics", - logging: "Logging", - gateway: "Gateway", - nodeHost: "Node Host", - agents: "Agents", - tools: "Tools", - bindings: "Bindings", - audio: "Audio", - models: "Models", - messages: "Messages", - commands: "Commands", - session: "Session", - cron: "Cron", - hooks: "Hooks", - ui: "UI", - browser: "Browser", - talk: "Talk", - channels: "Messaging Channels", - skills: "Skills", - plugins: "Plugins", - discovery: "Discovery", - presence: "Presence", - voicewake: "Voice Wake", -}; - -export const GROUP_ORDER: Record = { - wizard: 20, - update: 25, - diagnostics: 27, - gateway: 30, - nodeHost: 35, - agents: 40, - tools: 50, - bindings: 55, - audio: 60, - models: 70, - messages: 80, - commands: 85, - session: 90, - cron: 100, - hooks: 110, - ui: 120, - browser: 130, - talk: 140, - channels: 150, - skills: 200, - plugins: 205, - discovery: 210, - presence: 220, - voicewake: 230, - logging: 900, -}; - -export const FIELD_LABELS: Record = { - "meta.lastTouchedVersion": "Config Last Touched Version", - "meta.lastTouchedAt": "Config Last Touched At", - "update.channel": "Update Channel", - "update.checkOnStart": "Update Check on Start", - "diagnostics.enabled": "Diagnostics Enabled", - "diagnostics.flags": "Diagnostics Flags", - "diagnostics.otel.enabled": "OpenTelemetry Enabled", - "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", - "diagnostics.otel.protocol": "OpenTelemetry Protocol", - "diagnostics.otel.headers": "OpenTelemetry Headers", - "diagnostics.otel.serviceName": "OpenTelemetry Service Name", - "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", - "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", - "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", - "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", - "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", - "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", - "diagnostics.cacheTrace.filePath": "Cache Trace File Path", - "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", - "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", - "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", - "agents.list.*.identity.avatar": "Identity Avatar", - "agents.list.*.skills": "Agent Skill Filter", - "gateway.remote.url": "Remote Gateway URL", - "gateway.remote.sshTarget": "Remote Gateway SSH Target", - "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", - "gateway.remote.token": "Remote Gateway Token", - "gateway.remote.password": "Remote Gateway Password", - "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", - "gateway.auth.token": "Gateway Token", - "gateway.auth.password": "Gateway Password", - "tools.media.image.enabled": "Enable Image Understanding", - "tools.media.image.maxBytes": "Image Understanding Max Bytes", - "tools.media.image.maxChars": "Image Understanding Max Chars", - "tools.media.image.prompt": "Image Understanding Prompt", - "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", - "tools.media.image.attachments": "Image Understanding Attachment Policy", - "tools.media.image.models": "Image Understanding Models", - "tools.media.image.scope": "Image Understanding Scope", - "tools.media.models": "Media Understanding Shared Models", - "tools.media.concurrency": "Media Understanding Concurrency", - "tools.media.audio.enabled": "Enable Audio Understanding", - "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", - "tools.media.audio.maxChars": "Audio Understanding Max Chars", - "tools.media.audio.prompt": "Audio Understanding Prompt", - "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", - "tools.media.audio.language": "Audio Understanding Language", - "tools.media.audio.attachments": "Audio Understanding Attachment Policy", - "tools.media.audio.models": "Audio Understanding Models", - "tools.media.audio.scope": "Audio Understanding Scope", - "tools.media.video.enabled": "Enable Video Understanding", - "tools.media.video.maxBytes": "Video Understanding Max Bytes", - "tools.media.video.maxChars": "Video Understanding Max Chars", - "tools.media.video.prompt": "Video Understanding Prompt", - "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", - "tools.media.video.attachments": "Video Understanding Attachment Policy", - "tools.media.video.models": "Video Understanding Models", - "tools.media.video.scope": "Video Understanding Scope", - "tools.links.enabled": "Enable Link Understanding", - "tools.links.maxLinks": "Link Understanding Max Links", - "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", - "tools.links.models": "Link Understanding Models", - "tools.links.scope": "Link Understanding Scope", - "tools.profile": "Tool Profile", - "tools.alsoAllow": "Tool Allowlist Additions", - "agents.list[].tools.profile": "Agent Tool Profile", - "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", - "tools.byProvider": "Tool Policy by Provider", - "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", - "tools.exec.applyPatch.enabled": "Enable apply_patch", - "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", - "tools.exec.notifyOnExit": "Exec Notify On Exit", - "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", - "tools.exec.host": "Exec Host", - "tools.exec.security": "Exec Security", - "tools.exec.ask": "Exec Ask", - "tools.exec.node": "Exec Node Binding", - "tools.exec.pathPrepend": "Exec PATH Prepend", - "tools.exec.safeBins": "Exec Safe Bins", - "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)", - "tools.message.crossContext.marker.enabled": "Cross-Context Marker", - "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", - "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", - "tools.message.broadcast.enabled": "Enable Message Broadcast", - "tools.web.search.enabled": "Enable Web Search Tool", - "tools.web.search.provider": "Web Search Provider", - "tools.web.search.apiKey": "Brave Search API Key", - "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.fetch.enabled": "Enable Web Fetch Tool", - "tools.web.fetch.maxChars": "Web Fetch 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", - "gateway.controlUi.basePath": "Control UI Base Path", - "gateway.controlUi.root": "Control UI Assets Root", - "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", - "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", - "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", - "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", - "gateway.reload.mode": "Config Reload Mode", - "gateway.reload.debounceMs": "Config Reload Debounce (ms)", - "gateway.nodes.browser.mode": "Gateway Node Browser Mode", - "gateway.nodes.browser.node": "Gateway Node Browser Pin", - "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", - "gateway.nodes.denyCommands": "Gateway Node Denylist", - "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", - "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", - "skills.load.watch": "Watch Skills", - "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", - "agents.defaults.workspace": "Workspace", - "agents.defaults.repoRoot": "Repo Root", - "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", - "agents.defaults.envelopeTimezone": "Envelope Timezone", - "agents.defaults.envelopeTimestamp": "Envelope Timestamp", - "agents.defaults.envelopeElapsed": "Envelope Elapsed", - "agents.defaults.memorySearch": "Memory Search", - "agents.defaults.memorySearch.enabled": "Enable Memory Search", - "agents.defaults.memorySearch.sources": "Memory Search Sources", - "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", - "agents.defaults.memorySearch.experimental.sessionMemory": - "Memory Search Session Index (Experimental)", - "agents.defaults.memorySearch.provider": "Memory Search Provider", - "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.concurrency": "Remote Batch Concurrency", - "agents.defaults.memorySearch.model": "Memory Search Model", - "agents.defaults.memorySearch.fallback": "Memory Search Fallback", - "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", - "agents.defaults.memorySearch.store.path": "Memory Search Index Path", - "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", - "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", - "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", - "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", - "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", - "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", - "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", - "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", - "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", - "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", - "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", - "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", - "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", - "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", - "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", - "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": - "Memory Search Hybrid Candidate Multiplier", - "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", - "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", - memory: "Memory", - "memory.backend": "Memory Backend", - "memory.citations": "Memory Citations Mode", - "memory.qmd.command": "QMD Binary", - "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", - "memory.qmd.paths": "QMD Extra Paths", - "memory.qmd.paths.path": "QMD Path", - "memory.qmd.paths.pattern": "QMD Path Pattern", - "memory.qmd.paths.name": "QMD Path Name", - "memory.qmd.sessions.enabled": "QMD Session Indexing", - "memory.qmd.sessions.exportDir": "QMD Session Export Directory", - "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", - "memory.qmd.update.interval": "QMD Update Interval", - "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", - "memory.qmd.update.onBoot": "QMD Update on Startup", - "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", - "memory.qmd.update.embedInterval": "QMD Embed Interval", - "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", - "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", - "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", - "memory.qmd.limits.maxResults": "QMD Max Results", - "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", - "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", - "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", - "memory.qmd.scope": "QMD Surface Scope", - "auth.profiles": "Auth Profiles", - "auth.order": "Auth Profile Order", - "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", - "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", - "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", - "auth.cooldowns.failureWindowHours": "Failover Window (hours)", - "agents.defaults.models": "Models", - "agents.defaults.model.primary": "Primary Model", - "agents.defaults.model.fallbacks": "Model Fallbacks", - "agents.defaults.imageModel.primary": "Image Model", - "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", - "agents.defaults.humanDelay.mode": "Human Delay Mode", - "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", - "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", - "agents.defaults.cliBackends": "CLI Backends", - "commands.native": "Native Commands", - "commands.nativeSkills": "Native Skill Commands", - "commands.text": "Text Commands", - "commands.bash": "Allow Bash Chat Command", - "commands.bashForegroundMs": "Bash Foreground Window (ms)", - "commands.config": "Allow /config", - "commands.debug": "Allow /debug", - "commands.restart": "Allow Restart", - "commands.useAccessGroups": "Use Access Groups", - "commands.ownerAllowFrom": "Command Owners", - "commands.allowFrom": "Command Access Allowlist", - "ui.seamColor": "Accent Color", - "ui.assistant.name": "Assistant Name", - "ui.assistant.avatar": "Assistant Avatar", - "browser.evaluateEnabled": "Browser Evaluate Enabled", - "browser.snapshotDefaults": "Browser Snapshot Defaults", - "browser.snapshotDefaults.mode": "Browser Snapshot Mode", - "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", - "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", - "session.dmScope": "DM Session Scope", - "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", - "messages.ackReaction": "Ack Reaction Emoji", - "messages.ackReactionScope": "Ack Reaction Scope", - "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", - "talk.apiKey": "Talk API Key", - "channels.whatsapp": "WhatsApp", - "channels.telegram": "Telegram", - "channels.telegram.customCommands": "Telegram Custom Commands", - "channels.discord": "Discord", - "channels.slack": "Slack", - "channels.mattermost": "Mattermost", - "channels.signal": "Signal", - "channels.imessage": "iMessage", - "channels.bluebubbles": "BlueBubbles", - "channels.msteams": "MS Teams", - "channels.telegram.botToken": "Telegram Bot Token", - "channels.telegram.dmPolicy": "Telegram DM Policy", - "channels.telegram.streamMode": "Telegram Draft Stream Mode", - "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", - "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", - "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", - "channels.telegram.retry.attempts": "Telegram Retry Attempts", - "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", - "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", - "channels.telegram.retry.jitter": "Telegram Retry Jitter", - "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", - "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", - "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", - "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", - "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", - "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", - "channels.signal.dmPolicy": "Signal DM Policy", - "channels.imessage.dmPolicy": "iMessage DM Policy", - "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", - "channels.discord.dm.policy": "Discord DM Policy", - "channels.discord.retry.attempts": "Discord Retry Attempts", - "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", - "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", - "channels.discord.retry.jitter": "Discord Retry Jitter", - "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", - "channels.discord.intents.presence": "Discord Presence Intent", - "channels.discord.intents.guildMembers": "Discord Guild Members Intent", - "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", - "channels.discord.pluralkit.token": "Discord PluralKit Token", - "channels.slack.dm.policy": "Slack DM Policy", - "channels.slack.allowBots": "Slack Allow Bot Messages", - "channels.discord.token": "Discord Bot Token", - "channels.slack.botToken": "Slack Bot Token", - "channels.slack.appToken": "Slack App Token", - "channels.slack.userToken": "Slack User Token", - "channels.slack.userTokenReadOnly": "Slack User Token Read Only", - "channels.slack.thread.historyScope": "Slack Thread History Scope", - "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", - "channels.slack.thread.initialHistoryLimit": "Slack Thread Initial History Limit", - "channels.mattermost.botToken": "Mattermost Bot Token", - "channels.mattermost.baseUrl": "Mattermost Base URL", - "channels.mattermost.chatmode": "Mattermost Chat Mode", - "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", - "channels.mattermost.requireMention": "Mattermost Require Mention", - "channels.signal.account": "Signal Account", - "channels.imessage.cliPath": "iMessage CLI Path", - "agents.list[].skills": "Agent Skill Filter", - "agents.list[].identity.avatar": "Agent Avatar", - "discovery.mdns.mode": "mDNS Discovery Mode", - "plugins.enabled": "Enable Plugins", - "plugins.allow": "Plugin Allowlist", - "plugins.deny": "Plugin Denylist", - "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.*.config": "Plugin Config", - "plugins.installs": "Plugin Install Records", - "plugins.installs.*.source": "Plugin Install Source", - "plugins.installs.*.spec": "Plugin Install Spec", - "plugins.installs.*.sourcePath": "Plugin Install Source Path", - "plugins.installs.*.installPath": "Plugin Install Path", - "plugins.installs.*.version": "Plugin Install Version", - "plugins.installs.*.installedAt": "Plugin Install Time", -}; +import { IRC_FIELD_HELP } from "./schema.irc.js"; export const FIELD_HELP: Record = { "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", @@ -511,7 +160,7 @@ export const FIELD_HELP: Record = { "agents.defaults.memorySearch.remote.headers": "Extra headers for remote embeddings (merged; remote overrides OpenAI headers).", "agents.defaults.memorySearch.remote.batch.enabled": - "Enable batch API for memory embeddings (OpenAI/Gemini/Voyage; default: false).", + "Enable batch API for memory embeddings (OpenAI/Gemini; default: true).", "agents.defaults.memorySearch.remote.batch.wait": "Wait for batch completion when indexing (default: true).", "agents.defaults.memorySearch.remote.batch.concurrency": @@ -632,8 +281,6 @@ export const FIELD_HELP: Record = { "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "commands.ownerAllowFrom": "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", - "commands.allowFrom": - 'Per-provider allowlist restricting who can use slash commands. If set, overrides the channel\'s allowFrom for command authorization. Use \'*\' key for global default; provider-specific keys (e.g. \'discord\') override the global. Example: { "*": ["user1"], "discord": ["user:123"] }.', "session.dmScope": 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', "session.identityLinks": @@ -654,6 +301,7 @@ export const FIELD_HELP: Record = { "Allow iMessage to write config in response to channel events/commands (default: true).", "channels.msteams.configWrites": "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", + ...IRC_FIELD_HELP, "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', "channels.discord.commands.nativeSkills": 'Override native skill commands for Discord (bool or "auto").', @@ -722,20 +370,3 @@ export const FIELD_HELP: Record = { "channels.slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', }; - -export const FIELD_PLACEHOLDERS: Record = { - "gateway.remote.url": "ws://host:18789", - "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", - "gateway.remote.sshTarget": "user@host", - "gateway.controlUi.basePath": "/openclaw", - "gateway.controlUi.root": "dist/control-ui", - "gateway.controlUi.allowedOrigins": "https://control.example.com", - "channels.mattermost.baseUrl": "https://chat.example.com", - "agents.list[].identity.avatar": "avatars/openclaw.png", -}; - -export const SENSITIVE_PATTERNS = [/token$/i, /password/i, /secret/i, /api.?key/i]; - -export function isSensitivePath(path: string): boolean { - return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); -} diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts new file mode 100644 index 0000000000..0df9cf123a --- /dev/null +++ b/src/config/schema.hints.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { __test__ } from "./schema.hints.js"; +import { OpenClawSchema } from "./zod-schema.js"; +import { sensitive } from "./zod-schema.sensitive.js"; + +const { mapSensitivePaths } = __test__; + +describe("mapSensitivePaths", () => { + it("should detect sensitive fields nested inside all structural Zod types", () => { + const GrandSchema = z.object({ + simple: z.string().register(sensitive).optional(), + simpleReversed: z.string().optional().register(sensitive), + nested: z.object({ + nested: z.string().register(sensitive), + }), + list: z.array(z.string().register(sensitive)), + listOfObjects: z.array(z.object({ nested: z.string().register(sensitive) })), + headers: z.record(z.string(), z.string().register(sensitive)), + headersNested: z.record(z.string(), z.object({ nested: z.string().register(sensitive) })), + auth: z.union([ + z.object({ type: z.literal("none") }), + z.object({ type: z.literal("token"), value: z.string().register(sensitive) }), + ]), + merged: z + .object({ id: z.string() }) + .and(z.object({ nested: z.string().register(sensitive) })), + }); + + const result = mapSensitivePaths(GrandSchema, "", {}); + + expect(result["simple"]?.sensitive).toBe(true); + expect(result["simpleReversed"]?.sensitive).toBe(true); + expect(result["nested.nested"]?.sensitive).toBe(true); + expect(result["list[]"]?.sensitive).toBe(true); + expect(result["listOfObjects[].nested"]?.sensitive).toBe(true); + expect(result["headers.*"]?.sensitive).toBe(true); + expect(result["headersNested.*.nested"]?.sensitive).toBe(true); + expect(result["auth.value"]?.sensitive).toBe(true); + expect(result["merged.nested"]?.sensitive).toBe(true); + }); + + it("should not detect non-sensitive fields nested inside all structural Zod types", () => { + const GrandSchema = z.object({ + simple: z.string().optional(), + simpleReversed: z.string().optional(), + nested: z.object({ + nested: z.string(), + }), + list: z.array(z.string()), + listOfObjects: z.array(z.object({ nested: z.string() })), + headers: z.record(z.string(), z.string()), + headersNested: z.record(z.string(), z.object({ nested: z.string() })), + auth: z.union([ + z.object({ type: z.literal("none") }), + z.object({ type: z.literal("token"), value: z.string() }), + ]), + merged: z.object({ id: z.string() }).and(z.object({ nested: z.string() })), + }); + + const result = mapSensitivePaths(GrandSchema, "", {}); + + expect(result["simple"]?.sensitive).toBe(undefined); + expect(result["simpleReversed"]?.sensitive).toBe(undefined); + expect(result["nested.nested"]?.sensitive).toBe(undefined); + expect(result["list[]"]?.sensitive).toBe(undefined); + expect(result["listOfObjects[].nested"]?.sensitive).toBe(undefined); + expect(result["headers.*"]?.sensitive).toBe(undefined); + expect(result["headersNested.*.nested"]?.sensitive).toBe(undefined); + expect(result["auth.value"]?.sensitive).toBe(undefined); + expect(result["merged.nested"]?.sensitive).toBe(undefined); + }); + + it("main schema yields correct hints (samples)", () => { + const schema = OpenClawSchema.toJSONSchema({ + target: "draft-07", + unrepresentable: "any", + }); + schema.title = "OpenClawConfig"; + const hints = mapSensitivePaths(OpenClawSchema, "", {}); + + expect(hints["agents.defaults.memorySearch.remote.apiKey"]?.sensitive).toBe(true); + expect(hints["agents.list[].memorySearch.remote.apiKey"]?.sensitive).toBe(true); + expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true); + expect(hints["gateway.auth.token"]?.sensitive).toBe(true); + expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true); + }); +}); diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index c31a14d472..a39500ae58 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -1,4 +1,10 @@ -import { IRC_FIELD_HELP, IRC_FIELD_LABELS } from "./schema.irc.js"; +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 { sensitive } from "./zod-schema.sensitive.js"; + +const log = createSubsystemLogger("config/schema"); export type ConfigUiHint = { label?: string; @@ -69,674 +75,6 @@ const GROUP_ORDER: Record = { logging: 900, }; -const FIELD_LABELS: Record = { - "meta.lastTouchedVersion": "Config Last Touched Version", - "meta.lastTouchedAt": "Config Last Touched At", - "update.channel": "Update Channel", - "update.checkOnStart": "Update Check on Start", - "diagnostics.enabled": "Diagnostics Enabled", - "diagnostics.flags": "Diagnostics Flags", - "diagnostics.otel.enabled": "OpenTelemetry Enabled", - "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", - "diagnostics.otel.protocol": "OpenTelemetry Protocol", - "diagnostics.otel.headers": "OpenTelemetry Headers", - "diagnostics.otel.serviceName": "OpenTelemetry Service Name", - "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", - "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", - "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", - "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", - "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", - "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", - "diagnostics.cacheTrace.filePath": "Cache Trace File Path", - "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", - "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", - "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", - "agents.list.*.identity.avatar": "Identity Avatar", - "agents.list.*.skills": "Agent Skill Filter", - "gateway.remote.url": "Remote Gateway URL", - "gateway.remote.sshTarget": "Remote Gateway SSH Target", - "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", - "gateway.remote.token": "Remote Gateway Token", - "gateway.remote.password": "Remote Gateway Password", - "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", - "gateway.auth.token": "Gateway Token", - "gateway.auth.password": "Gateway Password", - "tools.media.image.enabled": "Enable Image Understanding", - "tools.media.image.maxBytes": "Image Understanding Max Bytes", - "tools.media.image.maxChars": "Image Understanding Max Chars", - "tools.media.image.prompt": "Image Understanding Prompt", - "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", - "tools.media.image.attachments": "Image Understanding Attachment Policy", - "tools.media.image.models": "Image Understanding Models", - "tools.media.image.scope": "Image Understanding Scope", - "tools.media.models": "Media Understanding Shared Models", - "tools.media.concurrency": "Media Understanding Concurrency", - "tools.media.audio.enabled": "Enable Audio Understanding", - "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", - "tools.media.audio.maxChars": "Audio Understanding Max Chars", - "tools.media.audio.prompt": "Audio Understanding Prompt", - "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", - "tools.media.audio.language": "Audio Understanding Language", - "tools.media.audio.attachments": "Audio Understanding Attachment Policy", - "tools.media.audio.models": "Audio Understanding Models", - "tools.media.audio.scope": "Audio Understanding Scope", - "tools.media.video.enabled": "Enable Video Understanding", - "tools.media.video.maxBytes": "Video Understanding Max Bytes", - "tools.media.video.maxChars": "Video Understanding Max Chars", - "tools.media.video.prompt": "Video Understanding Prompt", - "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", - "tools.media.video.attachments": "Video Understanding Attachment Policy", - "tools.media.video.models": "Video Understanding Models", - "tools.media.video.scope": "Video Understanding Scope", - "tools.links.enabled": "Enable Link Understanding", - "tools.links.maxLinks": "Link Understanding Max Links", - "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", - "tools.links.models": "Link Understanding Models", - "tools.links.scope": "Link Understanding Scope", - "tools.profile": "Tool Profile", - "tools.alsoAllow": "Tool Allowlist Additions", - "agents.list[].tools.profile": "Agent Tool Profile", - "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", - "tools.byProvider": "Tool Policy by Provider", - "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", - "tools.exec.applyPatch.enabled": "Enable apply_patch", - "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", - "tools.exec.notifyOnExit": "Exec Notify On Exit", - "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", - "tools.exec.host": "Exec Host", - "tools.exec.security": "Exec Security", - "tools.exec.ask": "Exec Ask", - "tools.exec.node": "Exec Node Binding", - "tools.exec.pathPrepend": "Exec PATH Prepend", - "tools.exec.safeBins": "Exec Safe Bins", - "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)", - "tools.message.crossContext.marker.enabled": "Cross-Context Marker", - "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", - "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", - "tools.message.broadcast.enabled": "Enable Message Broadcast", - "tools.web.search.enabled": "Enable Web Search Tool", - "tools.web.search.provider": "Web Search Provider", - "tools.web.search.apiKey": "Brave Search API Key", - "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.fetch.enabled": "Enable Web Fetch Tool", - "tools.web.fetch.maxChars": "Web Fetch 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", - "gateway.controlUi.basePath": "Control UI Base Path", - "gateway.controlUi.root": "Control UI Assets Root", - "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", - "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", - "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", - "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", - "gateway.reload.mode": "Config Reload Mode", - "gateway.reload.debounceMs": "Config Reload Debounce (ms)", - "gateway.nodes.browser.mode": "Gateway Node Browser Mode", - "gateway.nodes.browser.node": "Gateway Node Browser Pin", - "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", - "gateway.nodes.denyCommands": "Gateway Node Denylist", - "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", - "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", - "skills.load.watch": "Watch Skills", - "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", - "agents.defaults.workspace": "Workspace", - "agents.defaults.repoRoot": "Repo Root", - "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", - "agents.defaults.envelopeTimezone": "Envelope Timezone", - "agents.defaults.envelopeTimestamp": "Envelope Timestamp", - "agents.defaults.envelopeElapsed": "Envelope Elapsed", - "agents.defaults.memorySearch": "Memory Search", - "agents.defaults.memorySearch.enabled": "Enable Memory Search", - "agents.defaults.memorySearch.sources": "Memory Search Sources", - "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", - "agents.defaults.memorySearch.experimental.sessionMemory": - "Memory Search Session Index (Experimental)", - "agents.defaults.memorySearch.provider": "Memory Search Provider", - "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.concurrency": "Remote Batch Concurrency", - "agents.defaults.memorySearch.model": "Memory Search Model", - "agents.defaults.memorySearch.fallback": "Memory Search Fallback", - "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", - "agents.defaults.memorySearch.store.path": "Memory Search Index Path", - "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", - "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", - "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", - "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", - "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", - "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", - "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", - "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", - "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", - "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", - "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", - "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", - "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", - "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", - "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", - "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": - "Memory Search Hybrid Candidate Multiplier", - "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", - "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", - memory: "Memory", - "memory.backend": "Memory Backend", - "memory.citations": "Memory Citations Mode", - "memory.qmd.command": "QMD Binary", - "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", - "memory.qmd.paths": "QMD Extra Paths", - "memory.qmd.paths.path": "QMD Path", - "memory.qmd.paths.pattern": "QMD Path Pattern", - "memory.qmd.paths.name": "QMD Path Name", - "memory.qmd.sessions.enabled": "QMD Session Indexing", - "memory.qmd.sessions.exportDir": "QMD Session Export Directory", - "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", - "memory.qmd.update.interval": "QMD Update Interval", - "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", - "memory.qmd.update.onBoot": "QMD Update on Startup", - "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", - "memory.qmd.update.embedInterval": "QMD Embed Interval", - "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", - "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", - "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", - "memory.qmd.limits.maxResults": "QMD Max Results", - "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", - "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", - "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", - "memory.qmd.scope": "QMD Surface Scope", - "auth.profiles": "Auth Profiles", - "auth.order": "Auth Profile Order", - "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", - "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", - "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", - "auth.cooldowns.failureWindowHours": "Failover Window (hours)", - "agents.defaults.models": "Models", - "agents.defaults.model.primary": "Primary Model", - "agents.defaults.model.fallbacks": "Model Fallbacks", - "agents.defaults.imageModel.primary": "Image Model", - "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", - "agents.defaults.humanDelay.mode": "Human Delay Mode", - "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", - "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", - "agents.defaults.cliBackends": "CLI Backends", - "commands.native": "Native Commands", - "commands.nativeSkills": "Native Skill Commands", - "commands.text": "Text Commands", - "commands.bash": "Allow Bash Chat Command", - "commands.bashForegroundMs": "Bash Foreground Window (ms)", - "commands.config": "Allow /config", - "commands.debug": "Allow /debug", - "commands.restart": "Allow Restart", - "commands.useAccessGroups": "Use Access Groups", - "commands.ownerAllowFrom": "Command Owners", - "ui.seamColor": "Accent Color", - "ui.assistant.name": "Assistant Name", - "ui.assistant.avatar": "Assistant Avatar", - "browser.evaluateEnabled": "Browser Evaluate Enabled", - "browser.snapshotDefaults": "Browser Snapshot Defaults", - "browser.snapshotDefaults.mode": "Browser Snapshot Mode", - "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", - "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", - "session.dmScope": "DM Session Scope", - "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", - "messages.ackReaction": "Ack Reaction Emoji", - "messages.ackReactionScope": "Ack Reaction Scope", - "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", - "talk.apiKey": "Talk API Key", - "channels.whatsapp": "WhatsApp", - "channels.telegram": "Telegram", - "channels.telegram.customCommands": "Telegram Custom Commands", - "channels.discord": "Discord", - "channels.slack": "Slack", - "channels.mattermost": "Mattermost", - "channels.signal": "Signal", - "channels.imessage": "iMessage", - "channels.bluebubbles": "BlueBubbles", - "channels.msteams": "MS Teams", - ...IRC_FIELD_LABELS, - "channels.telegram.botToken": "Telegram Bot Token", - "channels.telegram.dmPolicy": "Telegram DM Policy", - "channels.telegram.streamMode": "Telegram Draft Stream Mode", - "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", - "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", - "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", - "channels.telegram.retry.attempts": "Telegram Retry Attempts", - "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", - "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", - "channels.telegram.retry.jitter": "Telegram Retry Jitter", - "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", - "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", - "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", - "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", - "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", - "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", - "channels.signal.dmPolicy": "Signal DM Policy", - "channels.imessage.dmPolicy": "iMessage DM Policy", - "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", - "channels.discord.dm.policy": "Discord DM Policy", - "channels.discord.retry.attempts": "Discord Retry Attempts", - "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", - "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", - "channels.discord.retry.jitter": "Discord Retry Jitter", - "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", - "channels.discord.intents.presence": "Discord Presence Intent", - "channels.discord.intents.guildMembers": "Discord Guild Members Intent", - "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", - "channels.discord.pluralkit.token": "Discord PluralKit Token", - "channels.slack.dm.policy": "Slack DM Policy", - "channels.slack.allowBots": "Slack Allow Bot Messages", - "channels.discord.token": "Discord Bot Token", - "channels.slack.botToken": "Slack Bot Token", - "channels.slack.appToken": "Slack App Token", - "channels.slack.userToken": "Slack User Token", - "channels.slack.userTokenReadOnly": "Slack User Token Read Only", - "channels.slack.thread.historyScope": "Slack Thread History Scope", - "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", - "channels.slack.thread.initialHistoryLimit": "Slack Thread Initial History Limit", - "channels.mattermost.botToken": "Mattermost Bot Token", - "channels.mattermost.baseUrl": "Mattermost Base URL", - "channels.mattermost.chatmode": "Mattermost Chat Mode", - "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", - "channels.mattermost.requireMention": "Mattermost Require Mention", - "channels.signal.account": "Signal Account", - "channels.imessage.cliPath": "iMessage CLI Path", - "agents.list[].skills": "Agent Skill Filter", - "agents.list[].identity.avatar": "Agent Avatar", - "discovery.mdns.mode": "mDNS Discovery Mode", - "plugins.enabled": "Enable Plugins", - "plugins.allow": "Plugin Allowlist", - "plugins.deny": "Plugin Denylist", - "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.*.config": "Plugin Config", - "plugins.installs": "Plugin Install Records", - "plugins.installs.*.source": "Plugin Install Source", - "plugins.installs.*.spec": "Plugin Install Spec", - "plugins.installs.*.sourcePath": "Plugin Install Source Path", - "plugins.installs.*.installPath": "Plugin Install Path", - "plugins.installs.*.version": "Plugin Install Version", - "plugins.installs.*.installedAt": "Plugin Install Time", -}; - -const FIELD_HELP: Record = { - "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", - "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", - "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").', - "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", - "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", - "gateway.remote.tlsFingerprint": - "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", - "gateway.remote.sshTarget": - "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", - "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", - "agents.list.*.skills": - "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", - "agents.list[].skills": - "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", - "agents.list[].identity.avatar": - "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", - "discovery.mdns.mode": - 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', - "gateway.auth.token": - "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", - "gateway.auth.password": "Required for Tailscale funnel.", - "gateway.controlUi.basePath": - "Optional URL prefix where the Control UI is served (e.g. /openclaw).", - "gateway.controlUi.root": - "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", - "gateway.controlUi.allowedOrigins": - "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).", - "gateway.controlUi.allowInsecureAuth": - "Allow Control UI auth over insecure HTTP (token-only; not recommended).", - "gateway.controlUi.dangerouslyDisableDeviceAuth": - "DANGEROUS. Disable Control UI device identity checks (token/password only).", - "gateway.http.endpoints.chatCompletions.enabled": - "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", - "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', - "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", - "gateway.nodes.browser.mode": - 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', - "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", - "gateway.nodes.allowCommands": - "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", - "gateway.nodes.denyCommands": - "Commands to block even if present in node claims or default allowlist.", - "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", - "nodeHost.browserProxy.allowProfiles": - "Optional allowlist of browser profile names exposed via the node proxy.", - "diagnostics.flags": - 'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".', - "diagnostics.cacheTrace.enabled": - "Log cache trace snapshots for embedded agent runs (default: false).", - "diagnostics.cacheTrace.filePath": - "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", - "diagnostics.cacheTrace.includeMessages": - "Include full message payloads in trace output (default: true).", - "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", - "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", - "tools.exec.applyPatch.enabled": - "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", - "tools.exec.applyPatch.allowModels": - 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', - "tools.exec.notifyOnExit": - "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", - "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", - "tools.exec.safeBins": - "Allow stdin-only safe binaries to run without explicit allowlist entries.", - "tools.message.allowCrossContextSend": - "Legacy override: allow cross-context sends across all providers.", - "tools.message.crossContext.allowWithinProvider": - "Allow sends to other channels within the same provider (default: true).", - "tools.message.crossContext.allowAcrossProviders": - "Allow sends across different providers (default: false).", - "tools.message.crossContext.marker.enabled": - "Add a visible origin marker when sending cross-context (default: true).", - "tools.message.crossContext.marker.prefix": - 'Text prefix for cross-context markers (supports "{channel}").', - "tools.message.crossContext.marker.suffix": - 'Text suffix for cross-context markers (supports "{channel}").', - "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", - "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", - "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', - "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", - "tools.web.search.maxResults": "Default number of results to return (1-10).", - "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", - "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", - "tools.web.search.perplexity.apiKey": - "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", - "tools.web.search.perplexity.baseUrl": - "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", - "tools.web.search.perplexity.model": - 'Perplexity model override (default: "perplexity/sonar-pro").', - "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", - "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", - "tools.web.fetch.maxCharsCap": - "Hard cap for web_fetch maxChars (applies to config and tool calls).", - "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", - "tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.", - "tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).", - "tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.", - "tools.web.fetch.readability": - "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", - "tools.web.fetch.firecrawl.enabled": "Enable Firecrawl fallback for web_fetch (if configured).", - "tools.web.fetch.firecrawl.apiKey": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", - "tools.web.fetch.firecrawl.baseUrl": - "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", - "tools.web.fetch.firecrawl.onlyMainContent": - "When true, Firecrawl returns only the main content (default: true).", - "tools.web.fetch.firecrawl.maxAgeMs": - "Firecrawl maxAge (ms) for cached results when supported by the API.", - "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", - "channels.slack.allowBots": - "Allow bot-authored messages to trigger Slack replies (default: false).", - "channels.slack.thread.historyScope": - 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', - "channels.slack.thread.inheritParent": - "If true, Slack thread sessions inherit the parent channel transcript (default: false).", - "channels.slack.thread.initialHistoryLimit": - "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).", - "channels.mattermost.botToken": - "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", - "channels.mattermost.baseUrl": - "Base URL for your Mattermost server (e.g., https://chat.example.com).", - "channels.mattermost.chatmode": - 'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").', - "channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).', - "channels.mattermost.requireMention": - "Require @mention in channels before responding (default: true).", - "auth.profiles": "Named auth profiles (provider + mode + optional email).", - "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", - "auth.cooldowns.billingBackoffHours": - "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", - "auth.cooldowns.billingBackoffHoursByProvider": - "Optional per-provider overrides for billing backoff (hours).", - "auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).", - "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", - "agents.defaults.bootstrapMaxChars": - "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", - "agents.defaults.repoRoot": - "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", - "agents.defaults.envelopeTimezone": - 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', - "agents.defaults.envelopeTimestamp": - 'Include absolute timestamps in message envelopes ("on" or "off").', - "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', - "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", - "agents.defaults.memorySearch": - "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", - "agents.defaults.memorySearch.sources": - 'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).', - "agents.defaults.memorySearch.extraPaths": - "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", - "agents.defaults.memorySearch.experimental.sessionMemory": - "Enable experimental session transcript indexing for memory search (default: false).", - "agents.defaults.memorySearch.provider": - 'Embedding provider ("openai", "gemini", "voyage", or "local").', - "agents.defaults.memorySearch.remote.baseUrl": - "Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).", - "agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.", - "agents.defaults.memorySearch.remote.headers": - "Extra headers for remote embeddings (merged; remote overrides OpenAI headers).", - "agents.defaults.memorySearch.remote.batch.enabled": - "Enable batch API for memory embeddings (OpenAI/Gemini; default: true).", - "agents.defaults.memorySearch.remote.batch.wait": - "Wait for batch completion when indexing (default: true).", - "agents.defaults.memorySearch.remote.batch.concurrency": - "Max concurrent embedding batch jobs for memory indexing (default: 2).", - "agents.defaults.memorySearch.remote.batch.pollIntervalMs": - "Polling interval in ms for batch status (default: 2000).", - "agents.defaults.memorySearch.remote.batch.timeoutMinutes": - "Timeout in minutes for batch indexing (default: 60).", - "agents.defaults.memorySearch.local.modelPath": - "Local GGUF model path or hf: URI (node-llama-cpp).", - "agents.defaults.memorySearch.fallback": - 'Fallback provider when embeddings fail ("openai", "gemini", "local", or "none").', - "agents.defaults.memorySearch.store.path": - "SQLite index path (default: ~/.openclaw/memory/{agentId}.sqlite).", - "agents.defaults.memorySearch.store.vector.enabled": - "Enable sqlite-vec extension for vector search (default: true).", - "agents.defaults.memorySearch.store.vector.extensionPath": - "Optional override path to sqlite-vec extension library (.dylib/.so/.dll).", - "agents.defaults.memorySearch.query.hybrid.enabled": - "Enable hybrid BM25 + vector search for memory (default: true).", - "agents.defaults.memorySearch.query.hybrid.vectorWeight": - "Weight for vector similarity when merging results (0-1).", - "agents.defaults.memorySearch.query.hybrid.textWeight": - "Weight for BM25 text relevance when merging results (0-1).", - "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": - "Multiplier for candidate pool size (default: 4).", - "agents.defaults.memorySearch.cache.enabled": - "Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).", - memory: "Memory backend configuration (global).", - "memory.backend": 'Memory backend ("builtin" for OpenClaw embeddings, "qmd" for QMD sidecar).', - "memory.citations": 'Default citation behavior ("auto", "on", or "off").', - "memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).", - "memory.qmd.includeDefaultMemory": - "Whether to automatically index MEMORY.md + memory/**/*.md (default: true).", - "memory.qmd.paths": - "Additional directories/files to index with QMD (path + optional glob pattern).", - "memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.", - "memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).", - "memory.qmd.paths.name": - "Optional stable name for the QMD collection (default derived from path).", - "memory.qmd.sessions.enabled": - "Enable QMD session transcript indexing (experimental, default: false).", - "memory.qmd.sessions.exportDir": - "Override directory for sanitized session exports before indexing.", - "memory.qmd.sessions.retentionDays": - "Retention window for exported sessions before pruning (default: unlimited).", - "memory.qmd.update.interval": - "How often the QMD sidecar refreshes indexes (duration string, default: 5m).", - "memory.qmd.update.debounceMs": - "Minimum delay between successive QMD refresh runs (default: 15000).", - "memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).", - "memory.qmd.update.waitForBootSync": - "Block startup until the boot QMD refresh finishes (default: false).", - "memory.qmd.update.embedInterval": - "How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.", - "memory.qmd.update.commandTimeoutMs": - "Timeout for QMD maintenance commands like collection list/add (default: 30000).", - "memory.qmd.update.updateTimeoutMs": "Timeout for `qmd update` runs (default: 120000).", - "memory.qmd.update.embedTimeoutMs": "Timeout for `qmd embed` runs (default: 120000).", - "memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).", - "memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).", - "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", - "memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).", - "memory.qmd.scope": - "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).", - "agents.defaults.memorySearch.cache.maxEntries": - "Optional cap on cached embeddings (best-effort).", - "agents.defaults.memorySearch.sync.onSearch": - "Lazy sync: schedule a reindex on search after changes.", - "agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).", - "agents.defaults.memorySearch.sync.sessions.deltaBytes": - "Minimum appended bytes before session transcripts trigger reindex (default: 100000).", - "agents.defaults.memorySearch.sync.sessions.deltaMessages": - "Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).", - "plugins.enabled": "Enable plugin/extension loading (default: true).", - "plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.", - "plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.", - "plugins.load.paths": "Additional plugin files or directories to load.", - "plugins.slots": "Select which plugins own exclusive slots (memory, etc.).", - "plugins.slots.memory": - 'Select the active memory plugin by id, or "none" to disable memory plugins.', - "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", - "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", - "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", - "plugins.installs": - "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", - "plugins.installs.*.source": 'Install source ("npm", "archive", or "path").', - "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", - "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", - "plugins.installs.*.installPath": - "Resolved install directory (usually ~/.openclaw/extensions/).", - "plugins.installs.*.version": "Version recorded at install time (if available).", - "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", - "agents.list.*.identity.avatar": - "Agent avatar (workspace-relative path, http(s) URL, or data URI).", - "agents.defaults.model.primary": "Primary model (provider/model).", - "agents.defaults.model.fallbacks": - "Ordered fallback models (provider/model). Used when the primary model fails.", - "agents.defaults.imageModel.primary": - "Optional image model (provider/model) used when the primary model lacks image input.", - "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", - "agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).", - "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', - "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", - "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", - "commands.native": - "Register native commands with channels that support it (Discord/Slack/Telegram).", - "commands.nativeSkills": - "Register native skill commands (user-invocable skills) with channels that support it.", - "commands.text": "Allow text command parsing (slash commands only).", - "commands.bash": - "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", - "commands.bashForegroundMs": - "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", - "commands.config": "Allow /config chat command to read/write config on disk (default: false).", - "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", - "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", - "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", - "commands.ownerAllowFrom": - "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", - "session.dmScope": - 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', - "session.identityLinks": - "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", - "channels.telegram.configWrites": - "Allow Telegram to write config in response to channel events/commands (default: true).", - "channels.slack.configWrites": - "Allow Slack to write config in response to channel events/commands (default: true).", - "channels.mattermost.configWrites": - "Allow Mattermost to write config in response to channel events/commands (default: true).", - "channels.discord.configWrites": - "Allow Discord to write config in response to channel events/commands (default: true).", - "channels.whatsapp.configWrites": - "Allow WhatsApp to write config in response to channel events/commands (default: true).", - "channels.signal.configWrites": - "Allow Signal to write config in response to channel events/commands (default: true).", - "channels.imessage.configWrites": - "Allow iMessage to write config in response to channel events/commands (default: true).", - "channels.msteams.configWrites": - "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", - ...IRC_FIELD_HELP, - "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', - "channels.discord.commands.nativeSkills": - 'Override native skill commands for Discord (bool or "auto").', - "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', - "channels.telegram.commands.nativeSkills": - 'Override native skill commands for Telegram (bool or "auto").', - "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', - "channels.slack.commands.nativeSkills": - 'Override native skill commands for Slack (bool or "auto").', - "session.agentToAgent.maxPingPongTurns": - "Max reply-back turns between requester and target (0–5).", - "channels.telegram.customCommands": - "Additional Telegram bot menu commands (merged with native; conflicts ignored).", - "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", - "messages.ackReactionScope": - 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', - "messages.inbound.debounceMs": - "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", - "channels.telegram.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', - "channels.telegram.streamMode": - "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", - "channels.telegram.draftChunk.minChars": - 'Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode="block" (default: 200).', - "channels.telegram.draftChunk.maxChars": - 'Target max size for a Telegram draft update chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).', - "channels.telegram.draftChunk.breakPreference": - "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", - "channels.telegram.retry.attempts": - "Max retry attempts for outbound Telegram API calls (default: 3).", - "channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.", - "channels.telegram.retry.maxDelayMs": - "Maximum retry delay cap in ms for Telegram outbound calls.", - "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", - "channels.telegram.network.autoSelectFamily": - "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", - "channels.telegram.timeoutSeconds": - "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", - "channels.whatsapp.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', - "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", - "channels.whatsapp.debounceMs": - "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", - "channels.signal.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', - "channels.imessage.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', - "channels.bluebubbles.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', - "channels.discord.dm.policy": - 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', - "channels.discord.retry.attempts": - "Max retry attempts for outbound Discord API calls (default: 3).", - "channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.", - "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", - "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", - "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", - "channels.discord.intents.presence": - "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", - "channels.discord.intents.guildMembers": - "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", - "channels.discord.pluralkit.enabled": - "Resolve PluralKit proxied messages and treat system members as distinct senders.", - "channels.discord.pluralkit.token": - "Optional PluralKit token for resolving private systems or members.", - "channels.slack.dm.policy": - 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', -}; - const FIELD_PLACEHOLDERS: Record = { "gateway.remote.url": "ws://host:18789", "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", @@ -748,10 +86,31 @@ const FIELD_PLACEHOLDERS: Record = { "agents.list[].identity.avatar": "avatars/openclaw.png", }; +/** + * Non-sensitive field names that happen to match sensitive patterns. + * These are explicitly excluded from redaction (plugin config) and + * warnings about not being marked sensitive (base config). + */ +const SENSITIVE_KEY_WHITELIST = new Set([ + "maxtokens", + "maxoutputtokens", + "maxinputtokens", + "maxcompletiontokens", + "contexttokens", + "totaltokens", + "tokencount", + "tokenlimit", + "tokenbudget", + "passwordFile", +]); + const SENSITIVE_PATTERNS = [/token$/i, /password/i, /secret/i, /api.?key/i]; -function isSensitiveConfigPath(path: string): boolean { - return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); +export function isSensitiveConfigPath(path: string): boolean { + return ( + !Array.from(SENSITIVE_KEY_WHITELIST).some((suffix) => path.endsWith(suffix)) && + SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)) + ); } export function buildBaseHints(): ConfigUiHints { @@ -778,12 +137,89 @@ export function buildBaseHints(): ConfigUiHints { return hints; } -export function applySensitiveHints(hints: ConfigUiHints): ConfigUiHints { +export function applySensitiveHints( + hints: ConfigUiHints, + allowedKeys?: ReadonlySet, +): ConfigUiHints { const next = { ...hints }; for (const key of Object.keys(next)) { + if (allowedKeys && !allowedKeys.has(key)) { + continue; + } + if (next[key]?.sensitive !== undefined) { + continue; + } if (isSensitiveConfigPath(key)) { next[key] = { ...next[key], sensitive: true }; } } return next; } + +// Seems to be the only way tsgo accepts us to check if we have a ZodClass +// with an unwrap() method. And it's overly complex because oxlint and +// tsgo are each forbidding what the other allows. +interface ZodDummy { + unwrap: () => z.ZodType; +} +function isUnwrappable(object: unknown): object is ZodDummy { + return ( + !!object && + typeof object === "object" && + "unwrap" in object && + typeof (object as Record).unwrap === "function" && + !(object instanceof z.ZodArray) + ); +} + +export function mapSensitivePaths( + schema: z.ZodType, + path: string, + hints: ConfigUiHints, +): ConfigUiHints { + let next = { ...hints }; + let currentSchema = schema; + let isSensitive = sensitive.has(currentSchema); + + while (isUnwrappable(currentSchema)) { + currentSchema = currentSchema.unwrap(); + isSensitive ||= sensitive.has(currentSchema); + } + + if (isSensitive) { + next[path] = { ...next[path], sensitive: true }; + } else if (isSensitiveConfigPath(path) && !next[path]?.sensitive) { + log.warn(`possibly sensitive key found: (${path})`); + } + + if (currentSchema instanceof z.ZodObject) { + const shape = currentSchema.shape; + for (const key in shape) { + const nextPath = path ? `${path}.${key}` : key; + next = mapSensitivePaths(shape[key], nextPath, next); + } + } else if (currentSchema instanceof z.ZodArray) { + const nextPath = path ? `${path}[]` : "[]"; + next = mapSensitivePaths(currentSchema.element as z.ZodType, nextPath, next); + } else if (currentSchema instanceof z.ZodRecord) { + const nextPath = path ? `${path}.*` : "*"; + next = mapSensitivePaths(currentSchema._def.valueType as z.ZodType, nextPath, next); + } else if ( + currentSchema instanceof z.ZodUnion || + currentSchema instanceof z.ZodDiscriminatedUnion + ) { + for (const option of currentSchema.options) { + next = mapSensitivePaths(option as z.ZodType, path, next); + } + } else if (currentSchema instanceof z.ZodIntersection) { + next = mapSensitivePaths(currentSchema._def.left as z.ZodType, path, next); + next = mapSensitivePaths(currentSchema._def.right as z.ZodType, path, next); + } + + return next; +} + +/** @internal */ +export const __test__ = { + mapSensitivePaths, +}; diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts new file mode 100644 index 0000000000..a91e89360f --- /dev/null +++ b/src/config/schema.labels.ts @@ -0,0 +1,298 @@ +import { IRC_FIELD_LABELS } from "./schema.irc.js"; + +export const FIELD_LABELS: Record = { + "meta.lastTouchedVersion": "Config Last Touched Version", + "meta.lastTouchedAt": "Config Last Touched At", + "update.channel": "Update Channel", + "update.checkOnStart": "Update Check on Start", + "diagnostics.enabled": "Diagnostics Enabled", + "diagnostics.flags": "Diagnostics Flags", + "diagnostics.otel.enabled": "OpenTelemetry Enabled", + "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", + "diagnostics.otel.protocol": "OpenTelemetry Protocol", + "diagnostics.otel.headers": "OpenTelemetry Headers", + "diagnostics.otel.serviceName": "OpenTelemetry Service Name", + "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", + "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", + "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", + "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", + "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", + "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", + "diagnostics.cacheTrace.filePath": "Cache Trace File Path", + "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", + "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", + "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", + "agents.list.*.identity.avatar": "Identity Avatar", + "agents.list.*.skills": "Agent Skill Filter", + "gateway.remote.url": "Remote Gateway URL", + "gateway.remote.sshTarget": "Remote Gateway SSH Target", + "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", + "gateway.remote.token": "Remote Gateway Token", + "gateway.remote.password": "Remote Gateway Password", + "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", + "gateway.auth.token": "Gateway Token", + "gateway.auth.password": "Gateway Password", + "tools.media.image.enabled": "Enable Image Understanding", + "tools.media.image.maxBytes": "Image Understanding Max Bytes", + "tools.media.image.maxChars": "Image Understanding Max Chars", + "tools.media.image.prompt": "Image Understanding Prompt", + "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", + "tools.media.image.attachments": "Image Understanding Attachment Policy", + "tools.media.image.models": "Image Understanding Models", + "tools.media.image.scope": "Image Understanding Scope", + "tools.media.models": "Media Understanding Shared Models", + "tools.media.concurrency": "Media Understanding Concurrency", + "tools.media.audio.enabled": "Enable Audio Understanding", + "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", + "tools.media.audio.maxChars": "Audio Understanding Max Chars", + "tools.media.audio.prompt": "Audio Understanding Prompt", + "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", + "tools.media.audio.language": "Audio Understanding Language", + "tools.media.audio.attachments": "Audio Understanding Attachment Policy", + "tools.media.audio.models": "Audio Understanding Models", + "tools.media.audio.scope": "Audio Understanding Scope", + "tools.media.video.enabled": "Enable Video Understanding", + "tools.media.video.maxBytes": "Video Understanding Max Bytes", + "tools.media.video.maxChars": "Video Understanding Max Chars", + "tools.media.video.prompt": "Video Understanding Prompt", + "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", + "tools.media.video.attachments": "Video Understanding Attachment Policy", + "tools.media.video.models": "Video Understanding Models", + "tools.media.video.scope": "Video Understanding Scope", + "tools.links.enabled": "Enable Link Understanding", + "tools.links.maxLinks": "Link Understanding Max Links", + "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", + "tools.links.models": "Link Understanding Models", + "tools.links.scope": "Link Understanding Scope", + "tools.profile": "Tool Profile", + "tools.alsoAllow": "Tool Allowlist Additions", + "agents.list[].tools.profile": "Agent Tool Profile", + "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", + "tools.byProvider": "Tool Policy by Provider", + "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", + "tools.exec.applyPatch.enabled": "Enable apply_patch", + "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", + "tools.exec.notifyOnExit": "Exec Notify On Exit", + "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", + "tools.exec.host": "Exec Host", + "tools.exec.security": "Exec Security", + "tools.exec.ask": "Exec Ask", + "tools.exec.node": "Exec Node Binding", + "tools.exec.pathPrepend": "Exec PATH Prepend", + "tools.exec.safeBins": "Exec Safe Bins", + "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)", + "tools.message.crossContext.marker.enabled": "Cross-Context Marker", + "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", + "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", + "tools.message.broadcast.enabled": "Enable Message Broadcast", + "tools.web.search.enabled": "Enable Web Search Tool", + "tools.web.search.provider": "Web Search Provider", + "tools.web.search.apiKey": "Brave Search API Key", + "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.fetch.enabled": "Enable Web Fetch Tool", + "tools.web.fetch.maxChars": "Web Fetch 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", + "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.controlUi.root": "Control UI Assets Root", + "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", + "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", + "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", + "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", + "gateway.reload.mode": "Config Reload Mode", + "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "gateway.nodes.browser.mode": "Gateway Node Browser Mode", + "gateway.nodes.browser.node": "Gateway Node Browser Pin", + "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", + "gateway.nodes.denyCommands": "Gateway Node Denylist", + "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", + "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", + "skills.load.watch": "Watch Skills", + "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", + "agents.defaults.workspace": "Workspace", + "agents.defaults.repoRoot": "Repo Root", + "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", + "agents.defaults.envelopeTimezone": "Envelope Timezone", + "agents.defaults.envelopeTimestamp": "Envelope Timestamp", + "agents.defaults.envelopeElapsed": "Envelope Elapsed", + "agents.defaults.memorySearch": "Memory Search", + "agents.defaults.memorySearch.enabled": "Enable Memory Search", + "agents.defaults.memorySearch.sources": "Memory Search Sources", + "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Memory Search Session Index (Experimental)", + "agents.defaults.memorySearch.provider": "Memory Search Provider", + "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.concurrency": "Remote Batch Concurrency", + "agents.defaults.memorySearch.model": "Memory Search Model", + "agents.defaults.memorySearch.fallback": "Memory Search Fallback", + "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", + "agents.defaults.memorySearch.store.path": "Memory Search Index Path", + "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", + "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", + "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", + "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", + "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", + "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", + "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", + "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", + "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", + "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", + "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", + "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Memory Search Hybrid Candidate Multiplier", + "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", + "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", + memory: "Memory", + "memory.backend": "Memory Backend", + "memory.citations": "Memory Citations Mode", + "memory.qmd.command": "QMD Binary", + "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", + "memory.qmd.paths": "QMD Extra Paths", + "memory.qmd.paths.path": "QMD Path", + "memory.qmd.paths.pattern": "QMD Path Pattern", + "memory.qmd.paths.name": "QMD Path Name", + "memory.qmd.sessions.enabled": "QMD Session Indexing", + "memory.qmd.sessions.exportDir": "QMD Session Export Directory", + "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", + "memory.qmd.update.interval": "QMD Update Interval", + "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", + "memory.qmd.update.onBoot": "QMD Update on Startup", + "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", + "memory.qmd.update.embedInterval": "QMD Embed Interval", + "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", + "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", + "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", + "memory.qmd.limits.maxResults": "QMD Max Results", + "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", + "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", + "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", + "memory.qmd.scope": "QMD Surface Scope", + "auth.profiles": "Auth Profiles", + "auth.order": "Auth Profile Order", + "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", + "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", + "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", + "auth.cooldowns.failureWindowHours": "Failover Window (hours)", + "agents.defaults.models": "Models", + "agents.defaults.model.primary": "Primary Model", + "agents.defaults.model.fallbacks": "Model Fallbacks", + "agents.defaults.imageModel.primary": "Image Model", + "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", + "agents.defaults.humanDelay.mode": "Human Delay Mode", + "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", + "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", + "agents.defaults.cliBackends": "CLI Backends", + "commands.native": "Native Commands", + "commands.nativeSkills": "Native Skill Commands", + "commands.text": "Text Commands", + "commands.bash": "Allow Bash Chat Command", + "commands.bashForegroundMs": "Bash Foreground Window (ms)", + "commands.config": "Allow /config", + "commands.debug": "Allow /debug", + "commands.restart": "Allow Restart", + "commands.useAccessGroups": "Use Access Groups", + "commands.ownerAllowFrom": "Command Owners", + "ui.seamColor": "Accent Color", + "ui.assistant.name": "Assistant Name", + "ui.assistant.avatar": "Assistant Avatar", + "browser.evaluateEnabled": "Browser Evaluate Enabled", + "browser.snapshotDefaults": "Browser Snapshot Defaults", + "browser.snapshotDefaults.mode": "Browser Snapshot Mode", + "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", + "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", + "session.dmScope": "DM Session Scope", + "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", + "messages.ackReaction": "Ack Reaction Emoji", + "messages.ackReactionScope": "Ack Reaction Scope", + "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", + "talk.apiKey": "Talk API Key", + "channels.whatsapp": "WhatsApp", + "channels.telegram": "Telegram", + "channels.telegram.customCommands": "Telegram Custom Commands", + "channels.discord": "Discord", + "channels.slack": "Slack", + "channels.mattermost": "Mattermost", + "channels.signal": "Signal", + "channels.imessage": "iMessage", + "channels.bluebubbles": "BlueBubbles", + "channels.msteams": "MS Teams", + ...IRC_FIELD_LABELS, + "channels.telegram.botToken": "Telegram Bot Token", + "channels.telegram.dmPolicy": "Telegram DM Policy", + "channels.telegram.streamMode": "Telegram Draft Stream Mode", + "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", + "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", + "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", + "channels.telegram.retry.attempts": "Telegram Retry Attempts", + "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", + "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", + "channels.telegram.retry.jitter": "Telegram Retry Jitter", + "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", + "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", + "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", + "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", + "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", + "channels.signal.dmPolicy": "Signal DM Policy", + "channels.imessage.dmPolicy": "iMessage DM Policy", + "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", + "channels.discord.dm.policy": "Discord DM Policy", + "channels.discord.retry.attempts": "Discord Retry Attempts", + "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", + "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", + "channels.discord.retry.jitter": "Discord Retry Jitter", + "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.intents.presence": "Discord Presence Intent", + "channels.discord.intents.guildMembers": "Discord Guild Members Intent", + "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", + "channels.discord.pluralkit.token": "Discord PluralKit Token", + "channels.slack.dm.policy": "Slack DM Policy", + "channels.slack.allowBots": "Slack Allow Bot Messages", + "channels.discord.token": "Discord Bot Token", + "channels.slack.botToken": "Slack Bot Token", + "channels.slack.appToken": "Slack App Token", + "channels.slack.userToken": "Slack User Token", + "channels.slack.userTokenReadOnly": "Slack User Token Read Only", + "channels.slack.thread.historyScope": "Slack Thread History Scope", + "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", + "channels.slack.thread.initialHistoryLimit": "Slack Thread Initial History Limit", + "channels.mattermost.botToken": "Mattermost Bot Token", + "channels.mattermost.baseUrl": "Mattermost Base URL", + "channels.mattermost.chatmode": "Mattermost Chat Mode", + "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", + "channels.mattermost.requireMention": "Mattermost Require Mention", + "channels.signal.account": "Signal Account", + "channels.imessage.cliPath": "iMessage CLI Path", + "agents.list[].skills": "Agent Skill Filter", + "agents.list[].identity.avatar": "Agent Avatar", + "discovery.mdns.mode": "mDNS Discovery Mode", + "plugins.enabled": "Enable Plugins", + "plugins.allow": "Plugin Allowlist", + "plugins.deny": "Plugin Denylist", + "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.*.config": "Plugin Config", + "plugins.installs": "Plugin Install Records", + "plugins.installs.*.source": "Plugin Install Source", + "plugins.installs.*.spec": "Plugin Install Spec", + "plugins.installs.*.sourcePath": "Plugin Install Source Path", + "plugins.installs.*.installPath": "Plugin Install Path", + "plugins.installs.*.version": "Plugin Install Version", + "plugins.installs.*.installedAt": "Plugin Install Time", +}; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index f7f90f37e4..e59eb2a9a7 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -36,6 +36,21 @@ describe("config schema", () => { expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.sensitive).toBe(true); }); + it("does not re-mark existing non-sensitive token-like fields", () => { + const res = buildConfigSchema({ + plugins: [ + { + id: "voice-call", + configUiHints: { + tokens: { label: "Tokens", sensitive: false }, + }, + }, + ], + }); + + expect(res.uiHints["plugins.entries.voice-call.config.tokens"]?.sensitive).toBe(false); + }); + it("merges plugin + channel schemas", () => { const res = buildConfigSchema({ plugins: [ diff --git a/src/config/schema.ts b/src/config/schema.ts index 1300673b27..8af49bce47 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,7 +1,7 @@ import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; import { CHANNEL_IDS } from "../channels/registry.js"; import { VERSION } from "../version.js"; -import { applySensitiveHints, buildBaseHints } from "./schema.hints.js"; +import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js"; import { OpenClawSchema } from "./zod-schema.js"; export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; @@ -88,6 +88,28 @@ export type ChannelUiMetadata = { configUiHints?: Record; }; +function collectExtensionHintKeys( + hints: ConfigUiHints, + plugins: PluginUiMetadata[], + channels: ChannelUiMetadata[], +): Set { + const pluginPrefixes = plugins + .map((plugin) => plugin.id.trim()) + .filter(Boolean) + .map((id) => `plugins.entries.${id}`); + const channelPrefixes = channels + .map((channel) => channel.id.trim()) + .filter(Boolean) + .map((id) => `channels.${id}`); + const prefixes = [...pluginPrefixes, ...channelPrefixes]; + + return new Set( + Object.keys(hints).filter((key) => + prefixes.some((prefix) => key === prefix || key.startsWith(`${prefix}.`)), + ), + ); +} + function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): ConfigUiHints { const next: ConfigUiHints = { ...hints }; for (const plugin of plugins) { @@ -299,7 +321,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse { unrepresentable: "any", }); schema.title = "OpenClawConfig"; - const hints = applySensitiveHints(buildBaseHints()); + const hints = mapSensitivePaths(OpenClawSchema, "", buildBaseHints()); const next = { schema: stripChannelSchema(schema), uiHints: hints, @@ -320,12 +342,16 @@ export function buildConfigSchema(params?: { if (plugins.length === 0 && channels.length === 0) { return base; } - const mergedHints = applySensitiveHints( - applyHeartbeatTargetHints( - applyChannelHints(applyPluginHints(base.uiHints, plugins), channels), - channels, - ), + const mergedWithoutSensitiveHints = applyHeartbeatTargetHints( + applyChannelHints(applyPluginHints(base.uiHints, plugins), channels), + channels, ); + const extensionHintKeys = collectExtensionHintKeys( + mergedWithoutSensitiveHints, + plugins, + channels, + ); + const mergedHints = applySensitiveHints(mergedWithoutSensitiveHints, extensionHintKeys); const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels); return { ...base, diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 035c3b23b1..8190c5bded 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -7,6 +7,7 @@ import { ToolsLinksSchema, ToolsMediaSchema, } from "./zod-schema.core.js"; +import { sensitive } from "./zod-schema.sensitive.js"; export const HeartbeatSchema = z .object({ @@ -172,13 +173,13 @@ export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok")]).optional(), - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(), perplexity: z .object({ - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), baseUrl: z.string().optional(), model: z.string().optional(), }) @@ -186,7 +187,7 @@ export const ToolsWebSearchSchema = z .optional(), grok: z .object({ - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), model: z.string().optional(), inlineCitations: z.boolean().optional(), }) @@ -332,7 +333,7 @@ export const MemorySearchSchema = z remote: z .object({ baseUrl: z.string().optional(), - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), headers: z.record(z.string(), z.string()).optional(), batch: z .object({ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 005ed3effd..b7da9208a7 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; +import { sensitive } from "./zod-schema.sensitive.js"; export const ModelApiSchema = z.union([ z.literal("openai-completions"), @@ -48,7 +49,7 @@ export const ModelDefinitionSchema = z export const ModelProviderSchema = z .object({ baseUrl: z.string().min(1), - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), auth: z .union([z.literal("api-key"), z.literal("aws-sdk"), z.literal("oauth"), z.literal("token")]) .optional(), @@ -180,7 +181,7 @@ export const TtsConfigSchema = z .optional(), elevenlabs: z .object({ - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), baseUrl: z.string().optional(), voiceId: z.string().optional(), modelId: z.string().optional(), @@ -202,7 +203,7 @@ export const TtsConfigSchema = z .optional(), openai: z .object({ - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), model: z.string().optional(), voice: z.string().optional(), }) diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 38651c4f24..d542e2e46d 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { sensitive } from "./zod-schema.sensitive.js"; export const HookMappingSchema = z .object({ @@ -13,7 +14,7 @@ export const HookMappingSchema = z wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(), name: z.string().optional(), agentId: z.string().optional(), - sessionKey: z.string().optional(), + sessionKey: z.string().optional().register(sensitive), messageTemplate: z.string().optional(), textTemplate: z.string().optional(), deliver: z.boolean().optional(), @@ -98,7 +99,7 @@ export const HooksGmailSchema = z label: z.string().optional(), topic: z.string().optional(), subscription: z.string().optional(), - pushToken: z.string().optional(), + pushToken: z.string().optional().register(sensitive), hookUrl: z.string().optional(), includeBody: z.boolean().optional(), maxBytes: z.number().int().positive().optional(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3e98ff3126..c377beecd7 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -20,6 +20,7 @@ import { RetryConfigSchema, requireOpenAllowFrom, } from "./zod-schema.core.js"; +import { sensitive } from "./zod-schema.sensitive.js"; const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional(); @@ -97,7 +98,7 @@ export const TelegramAccountSchemaBase = z customCommands: z.array(TelegramCustomCommandSchema).optional(), configWrites: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), - botToken: z.string().optional(), + botToken: z.string().optional().register(sensitive), tokenFile: z.string().optional(), replyToMode: ReplyToModeSchema.optional(), groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(), @@ -124,7 +125,7 @@ export const TelegramAccountSchemaBase = z .optional(), proxy: z.string().optional(), webhookUrl: z.string().optional(), - webhookSecret: z.string().optional(), + webhookSecret: z.string().optional().register(sensitive), webhookPath: z.string().optional(), actions: z .object({ @@ -263,7 +264,7 @@ export const DiscordAccountSchema = z enabled: z.boolean().optional(), commands: ProviderCommandsSchema, configWrites: z.boolean().optional(), - token: z.string().optional(), + token: z.string().optional().register(sensitive), allowBots: z.boolean().optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), historyLimit: z.number().int().min(0).optional(), @@ -324,7 +325,7 @@ export const DiscordAccountSchema = z pluralkit: z .object({ enabled: z.boolean().optional(), - token: z.string().optional(), + token: z.string().optional().register(sensitive), }) .strict() .optional(), @@ -463,16 +464,16 @@ export const SlackAccountSchema = z .object({ name: z.string().optional(), mode: z.enum(["socket", "http"]).optional(), - signingSecret: z.string().optional(), + signingSecret: z.string().optional().register(sensitive), webhookPath: z.string().optional(), capabilities: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, configWrites: z.boolean().optional(), - botToken: z.string().optional(), - appToken: z.string().optional(), - userToken: z.string().optional(), + botToken: z.string().optional().register(sensitive), + appToken: z.string().optional().register(sensitive), + userToken: z.string().optional().register(sensitive), userTokenReadOnly: z.boolean().optional().default(true), allowBots: z.boolean().optional(), requireMention: z.boolean().optional(), @@ -521,7 +522,7 @@ export const SlackAccountSchema = z export const SlackConfigSchema = SlackAccountSchema.extend({ mode: z.enum(["socket", "http"]).optional().default("socket"), - signingSecret: z.string().optional(), + signingSecret: z.string().optional().register(sensitive), webhookPath: z.string().optional().default("/slack/events"), accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(), }).superRefine((value, ctx) => { @@ -641,7 +642,7 @@ export const IrcNickServSchema = z .object({ enabled: z.boolean().optional(), service: z.string().optional(), - password: z.string().optional(), + password: z.string().optional().register(sensitive), passwordFile: z.string().optional(), register: z.boolean().optional(), registerEmail: z.string().optional(), @@ -661,7 +662,7 @@ export const IrcAccountSchemaBase = z nick: z.string().optional(), username: z.string().optional(), realname: z.string().optional(), - password: z.string().optional(), + password: z.string().optional().register(sensitive), passwordFile: z.string().optional(), nickserv: IrcNickServSchema.optional(), channels: z.array(z.string()).optional(), @@ -822,7 +823,7 @@ export const BlueBubblesAccountSchemaBase = z configWrites: z.boolean().optional(), enabled: z.boolean().optional(), serverUrl: z.string().optional(), - password: z.string().optional(), + password: z.string().optional().register(sensitive), webhookPath: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(BlueBubblesAllowFromEntry).optional(), @@ -893,7 +894,7 @@ export const MSTeamsConfigSchema = z markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), appId: z.string().optional(), - appPassword: z.string().optional(), + appPassword: z.string().optional().register(sensitive), tenantId: z.string().optional(), webhook: z .object({ diff --git a/src/config/zod-schema.sensitive.ts b/src/config/zod-schema.sensitive.ts new file mode 100644 index 0000000000..e656bb6777 --- /dev/null +++ b/src/config/zod-schema.sensitive.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +// Everything registered here will be redacted when the config is exposed, +// e.g. sent to the dashboard +export const sensitive = z.registry(); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index da1b76dc77..d5289c34cb 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -5,6 +5,7 @@ import { ApprovalsSchema } from "./zod-schema.approvals.js"; import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js"; import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; import { ChannelsSchema } from "./zod-schema.providers.js"; +import { sensitive } from "./zod-schema.sensitive.js"; import { CommandsSchema, MessagesSchema, @@ -301,7 +302,7 @@ export const OpenClawSchema = z .object({ enabled: z.boolean().optional(), path: z.string().optional(), - token: z.string().optional(), + token: z.string().optional().register(sensitive), defaultSessionKey: z.string().optional(), allowRequestSessionKey: z.boolean().optional(), allowedSessionKeyPrefixes: z.array(z.string()).optional(), @@ -365,7 +366,7 @@ export const OpenClawSchema = z voiceAliases: z.record(z.string(), z.string()).optional(), modelId: z.string().optional(), outputFormat: z.string().optional(), - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), interruptOnSpeech: z.boolean().optional(), }) .strict() @@ -397,8 +398,8 @@ export const OpenClawSchema = z auth: z .object({ mode: z.union([z.literal("token"), z.literal("password")]).optional(), - token: z.string().optional(), - password: z.string().optional(), + token: z.string().optional().register(sensitive), + password: z.string().optional().register(sensitive), allowTailscale: z.boolean().optional(), }) .strict() @@ -422,8 +423,8 @@ export const OpenClawSchema = z .object({ url: z.string().optional(), transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(), - token: z.string().optional(), - password: z.string().optional(), + token: z.string().optional().register(sensitive), + password: z.string().optional().register(sensitive), tlsFingerprint: z.string().optional(), sshTarget: z.string().optional(), sshIdentity: z.string().optional(), @@ -554,7 +555,7 @@ export const OpenClawSchema = z z .object({ enabled: z.boolean().optional(), - apiKey: z.string().optional(), + apiKey: z.string().optional().register(sensitive), env: z.record(z.string(), z.string()).optional(), config: z.record(z.string(), z.unknown()).optional(), }) diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 05a534454a..d4be1a8667 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -17,7 +17,7 @@ import { redactConfigSnapshot, restoreRedactedValues, } from "../../config/redact-snapshot.js"; -import { buildConfigSchema } from "../../config/schema.js"; +import { buildConfigSchema, type ConfigSchemaResponse } from "../../config/schema.js"; import { formatDoctorNonInteractiveHint, type RestartSentinelPayload, @@ -91,6 +91,41 @@ function requireConfigBaseHash( return true; } +function loadSchemaWithPlugins(): ConfigSchemaResponse { + const cfg = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const pluginRegistry = loadOpenClawPlugins({ + config: cfg, + cache: true, + workspaceDir, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + }); + // Note: We can't easily cache this, as there are no callback that can invalidate + // our cache. However, both loadConfig() and loadOpenClawPlugins() already cache + // their results, and buildConfigSchema() is just a cheap transformation. + return buildConfigSchema({ + plugins: pluginRegistry.plugins.map((plugin) => ({ + id: plugin.id, + name: plugin.name, + description: plugin.description, + configUiHints: plugin.configUiHints, + configSchema: plugin.configJsonSchema, + })), + channels: listChannelPlugins().map((entry) => ({ + id: entry.id, + label: entry.meta.label, + description: entry.meta.blurb, + configSchema: entry.configSchema?.schema, + configUiHints: entry.configSchema?.uiHints, + })), + }); +} + export const configHandlers: GatewayRequestHandlers = { "config.get": async ({ params, respond }) => { if (!validateConfigGetParams(params)) { @@ -105,7 +140,8 @@ export const configHandlers: GatewayRequestHandlers = { return; } const snapshot = await readConfigFileSnapshot(); - respond(true, redactConfigSnapshot(snapshot), undefined); + const schema = loadSchemaWithPlugins(); + respond(true, redactConfigSnapshot(snapshot, schema.uiHints), undefined); }, "config.schema": ({ params, respond }) => { if (!validateConfigSchemaParams(params)) { @@ -119,35 +155,7 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } - const cfg = loadConfig(); - const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); - const pluginRegistry = loadOpenClawPlugins({ - config: cfg, - workspaceDir, - logger: { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }, - }); - const schema = buildConfigSchema({ - plugins: pluginRegistry.plugins.map((plugin) => ({ - id: plugin.id, - name: plugin.name, - description: plugin.description, - configUiHints: plugin.configUiHints, - configSchema: plugin.configJsonSchema, - })), - channels: listChannelPlugins().map((entry) => ({ - id: entry.id, - label: entry.meta.label, - description: entry.meta.blurb, - configSchema: entry.configSchema?.schema, - configUiHints: entry.configSchema?.uiHints, - })), - }); - respond(true, schema, undefined); + respond(true, loadSchemaWithPlugins(), undefined); }, "config.set": async ({ params, respond }) => { if (!validateConfigSetParams(params)) { @@ -179,7 +187,17 @@ export const configHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } - const validated = validateConfigObjectWithPlugins(parsedRes.parsed); + const schemaSet = loadSchemaWithPlugins(); + const restored = restoreRedactedValues(parsedRes.parsed, snapshot.config, schemaSet.uiHints); + if (!restored.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, restored.humanReadableMessage ?? "invalid config"), + ); + return; + } + const validated = validateConfigObjectWithPlugins(restored.result); if (!validated.ok) { respond( false, @@ -190,27 +208,13 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } - let restored: typeof validated.config; - try { - restored = restoreRedactedValues( - validated.config, - snapshot.config, - ) as typeof validated.config; - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)), - ); - return; - } - await writeConfigFile(restored); + await writeConfigFile(validated.config); respond( true, { ok: true, path: CONFIG_PATH, - config: redactConfigObject(restored), + config: redactConfigObject(validated.config, schemaSet.uiHints), }, undefined, ); @@ -269,19 +273,21 @@ export const configHandlers: GatewayRequestHandlers = { return; } const merged = applyMergePatch(snapshot.config, parsedRes.parsed); - let restoredMerge: unknown; - try { - restoredMerge = restoreRedactedValues(merged, snapshot.config); - } catch (err) { + const schemaPatch = loadSchemaWithPlugins(); + const restoredMerge = restoreRedactedValues(merged, snapshot.config, schemaPatch.uiHints); + if (!restoredMerge.ok) { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)), + errorShape( + ErrorCodes.INVALID_REQUEST, + restoredMerge.humanReadableMessage ?? "invalid config", + ), ); return; } - const migrated = applyLegacyMigrations(restoredMerge); - const resolved = migrated.next ?? restoredMerge; + const migrated = applyLegacyMigrations(restoredMerge.result); + const resolved = migrated.next ?? restoredMerge.result; const validated = validateConfigObjectWithPlugins(resolved); if (!validated.ok) { respond( @@ -336,7 +342,7 @@ export const configHandlers: GatewayRequestHandlers = { { ok: true, path: CONFIG_PATH, - config: redactConfigObject(validated.config), + config: redactConfigObject(validated.config, schemaPatch.uiHints), restart, sentinel: { path: sentinelPath, @@ -379,7 +385,17 @@ export const configHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } - const validated = validateConfigObjectWithPlugins(parsedRes.parsed); + const schemaApply = loadSchemaWithPlugins(); + const restored = restoreRedactedValues(parsedRes.parsed, snapshot.config, schemaApply.uiHints); + if (!restored.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, restored.humanReadableMessage ?? "invalid config"), + ); + return; + } + const validated = validateConfigObjectWithPlugins(restored.result); if (!validated.ok) { respond( false, @@ -390,21 +406,7 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } - let restoredApply: typeof validated.config; - try { - restoredApply = restoreRedactedValues( - validated.config, - snapshot.config, - ) as typeof validated.config; - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)), - ); - return; - } - await writeConfigFile(restoredApply); + await writeConfigFile(validated.config); const sessionKey = typeof (params as { sessionKey?: unknown }).sessionKey === "string" @@ -447,7 +449,7 @@ export const configHandlers: GatewayRequestHandlers = { { ok: true, path: CONFIG_PATH, - config: redactConfigObject(restoredApply), + config: redactConfigObject(validated.config, schemaApply.uiHints), restart, sentinel: { path: sentinelPath, diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index c37ae6d125..f57fb834e1 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -4,7 +4,6 @@ import { defaultValue, hintForPath, humanize, - isSensitivePath, pathKey, schemaType, type JsonSchema, @@ -307,7 +306,8 @@ function renderTextInput(params: { const hint = hintForPath(path, hints); const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); const help = hint?.help ?? schema.description; - const isSensitive = hint?.sensitive ?? isSensitivePath(path); + const isSensitive = + (hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim()); const placeholder = hint?.placeholder ?? // oxlint-disable typescript/no-base-to-string diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts index 9a7f9fce50..c7e7d81abe 100644 --- a/ui/src/ui/views/config-form.shared.ts +++ b/ui/src/ui/views/config-form.shared.ts @@ -92,14 +92,3 @@ export function humanize(raw: string) { .replace(/\s+/g, " ") .replace(/^./, (m) => m.toUpperCase()); } - -export function isSensitivePath(path: Array): boolean { - const key = pathKey(path).toLowerCase(); - return ( - key.includes("token") || - key.includes("password") || - key.includes("secret") || - key.includes("apikey") || - key.endsWith("key") - ); -}