mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 19:01:47 +03:00
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 <steipete@gmail.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ConfigUiHints } from "./schema.js";
|
||||||
import type { ConfigFileSnapshot } from "./types.openclaw.js";
|
import type { ConfigFileSnapshot } from "./types.openclaw.js";
|
||||||
import {
|
import {
|
||||||
REDACTED_SENTINEL,
|
REDACTED_SENTINEL,
|
||||||
redactConfigSnapshot,
|
redactConfigSnapshot,
|
||||||
restoreRedactedValues,
|
restoreRedactedValues as restoreRedactedValues_orig,
|
||||||
} from "./redact-snapshot.js";
|
} from "./redact-snapshot.js";
|
||||||
|
import { __test__ } from "./schema.hints.js";
|
||||||
|
import { OpenClawSchema } from "./zod-schema.js";
|
||||||
|
|
||||||
|
const { mapSensitivePaths } = __test__;
|
||||||
|
|
||||||
function makeSnapshot(config: Record<string, unknown>, raw?: string): ConfigFileSnapshot {
|
function makeSnapshot(config: Record<string, unknown>, raw?: string): ConfigFileSnapshot {
|
||||||
return {
|
return {
|
||||||
@@ -22,6 +27,16 @@ function makeSnapshot(config: Record<string, unknown>, 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", () => {
|
describe("redactConfigSnapshot", () => {
|
||||||
it("redacts top-level token fields", () => {
|
it("redacts top-level token fields", () => {
|
||||||
const snapshot = makeSnapshot({
|
const snapshot = makeSnapshot({
|
||||||
@@ -217,6 +232,25 @@ describe("redactConfigSnapshot", () => {
|
|||||||
expect(result.parsed).toBeNull();
|
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", () => {
|
it("handles deeply nested tokens in accounts", () => {
|
||||||
const snapshot = makeSnapshot({
|
const snapshot = makeSnapshot({
|
||||||
channels: {
|
channels: {
|
||||||
@@ -259,35 +293,379 @@ describe("redactConfigSnapshot", () => {
|
|||||||
});
|
});
|
||||||
const result = redactConfigSnapshot(snapshot);
|
const result = redactConfigSnapshot(snapshot);
|
||||||
const env = result.config.env as Record<string, Record<string, string>>;
|
const env = result.config.env as Record<string, Record<string, string>>;
|
||||||
expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL);
|
|
||||||
// NODE_ENV is not sensitive, should be preserved
|
// NODE_ENV is not sensitive, should be preserved
|
||||||
expect(env.vars.NODE_ENV).toBe("production");
|
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", () => {
|
it("does NOT redact numeric 'tokens' fields (token regex fix)", () => {
|
||||||
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", () => {
|
|
||||||
const snapshot = makeSnapshot({
|
const snapshot = makeSnapshot({
|
||||||
gateway: { auth: { token: 1234 } },
|
memory: { tokens: 8192 },
|
||||||
});
|
});
|
||||||
const result = redactConfigSnapshot(snapshot);
|
const result = redactConfigSnapshot(snapshot);
|
||||||
|
const memory = result.config.memory as Record<string, number>;
|
||||||
|
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<string, number>;
|
||||||
|
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<string, string>;
|
||||||
|
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<string, Record<string, string>>;
|
||||||
|
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<string, string>;
|
||||||
|
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<string, Record<string, string>>;
|
const gw = result.config.gateway as Record<string, Record<string, string>>;
|
||||||
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<string, Record<string, string>>;
|
||||||
|
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<string, Array<Record<string, string>>>
|
||||||
|
>;
|
||||||
|
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 } },
|
channels: { newChannel: { token: REDACTED_SENTINEL } },
|
||||||
};
|
};
|
||||||
const original = {};
|
const original = {};
|
||||||
expect(() => restoreRedactedValues(incoming, original)).toThrow(/redacted/i);
|
expect(restoreRedactedValues_orig(incoming, original).ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles null and undefined inputs", () => {
|
it("handles null and undefined inputs", () => {
|
||||||
expect(restoreRedactedValues(null, { token: "x" })).toBeNull();
|
expect(restoreRedactedValues_orig(null, { token: "x" }).ok).toBe(false);
|
||||||
expect(restoreRedactedValues(undefined, { token: "x" })).toBeUndefined();
|
expect(restoreRedactedValues_orig(undefined, { token: "x" }).ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips config through redact → restore", () => {
|
it("round-trips config through redact → restore", () => {
|
||||||
@@ -398,4 +776,110 @@ describe("restoreRedactedValues", () => {
|
|||||||
|
|
||||||
expect(restored).toEqual(originalConfig);
|
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<string, string>;
|
||||||
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+432
-100
@@ -1,4 +1,37 @@
|
|||||||
import type { ConfigFileSnapshot } from "./types.openclaw.js";
|
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.
|
* 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__";
|
export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
|
||||||
|
|
||||||
/**
|
// ConfigUiHints' keys look like this:
|
||||||
* Non-sensitive field names that happen to match sensitive patterns.
|
// - path.subpath.key (nested objects)
|
||||||
* These are explicitly excluded from redaction.
|
// - path.subpath[].key (object in array in object)
|
||||||
*/
|
// - path.*.key (object in record in object)
|
||||||
const SENSITIVE_KEY_WHITELIST = new Set([
|
// records are handled by the lookup, but arrays need two entries in
|
||||||
"maxtokens",
|
// the Set, as their first lookup is done before the code knows it's
|
||||||
"maxoutputtokens",
|
// an array.
|
||||||
"maxinputtokens",
|
function buildRedactionLookup(hints: ConfigUiHints): Set<string> {
|
||||||
"maxcompletiontokens",
|
let result = new Set<string>();
|
||||||
"contexttokens",
|
|
||||||
"totaltokens",
|
|
||||||
"tokencount",
|
|
||||||
"tokenlimit",
|
|
||||||
"tokenbudget",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
for (const [path, hint] of Object.entries(hints)) {
|
||||||
* Patterns that identify sensitive config field names.
|
if (!hint.sensitive) {
|
||||||
* Aligned with the UI-hint logic in schema.ts.
|
continue;
|
||||||
*/
|
|
||||||
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<string, unknown> = {};
|
|
||||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redactConfigObject<T>(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.
|
* Collect all sensitive string values from a config object.
|
||||||
* Used for text-based redaction of the raw JSON5 source.
|
* Used for text-based redaction of the raw JSON5 source.
|
||||||
*/
|
*/
|
||||||
function collectSensitiveValues(obj: unknown): string[] {
|
function collectSensitiveValues(obj: unknown, hints?: ConfigUiHints): string[] {
|
||||||
const values: string[] = [];
|
const result: string[] = [];
|
||||||
if (obj === null || obj === undefined || typeof obj !== "object") {
|
if (hints) {
|
||||||
return values;
|
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<string>,
|
||||||
|
prefix: string,
|
||||||
|
values: string[],
|
||||||
|
hints: ConfigUiHints,
|
||||||
|
): unknown {
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
for (const item of obj) {
|
const path = `${prefix}[]`;
|
||||||
values.push(...collectSensitiveValues(item));
|
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<string, unknown>)) {
|
|
||||||
if (isSensitiveKey(key) && typeof value === "string" && value.length > 0) {
|
if (typeof obj === "object") {
|
||||||
values.push(value);
|
const result: Record<string, unknown> = {};
|
||||||
} else if (typeof value === "object" && value !== null) {
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
values.push(...collectSensitiveValues(value));
|
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<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
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.
|
* Replace known sensitive values in a raw JSON5 string with the sentinel.
|
||||||
* Values are replaced longest-first to avoid partial matches.
|
* Values are replaced longest-first to avoid partial matches.
|
||||||
*/
|
*/
|
||||||
function redactRawText(raw: string, config: unknown): string {
|
function redactRawText(raw: string, config: unknown, hints?: ConfigUiHints): string {
|
||||||
const sensitiveValues = collectSensitiveValues(config);
|
const sensitiveValues = collectSensitiveValues(config, hints);
|
||||||
sensitiveValues.sort((a, b) => b.length - a.length);
|
sensitiveValues.sort((a, b) => b.length - a.length);
|
||||||
let result = raw;
|
let result = raw;
|
||||||
for (const value of sensitiveValues) {
|
for (const value of sensitiveValues) {
|
||||||
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
result = result.replaceAll(value, REDACTED_SENTINEL);
|
||||||
result = result.replace(new RegExp(escaped, "g"), 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;
|
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
|
* Both `config` (the parsed object) and `raw` (the JSON5 source) are scrubbed
|
||||||
* so no credential can leak through either path.
|
* 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);
|
* Redact sensitive fields from a plain config object (not a full snapshot).
|
||||||
const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config) : null;
|
* Used by write endpoints (config.set, config.patch, config.apply) to avoid
|
||||||
const redactedParsed = snapshot.parsed ? redactConfigObject(snapshot.parsed) : snapshot.parsed;
|
* leaking credentials in their responses.
|
||||||
|
*/
|
||||||
|
export function redactConfigObject<T>(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)
|
// Also redact the resolved config (contains values after ${ENV} substitution)
|
||||||
const redactedResolved = redactConfigObject(snapshot.resolved);
|
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
|
* 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,
|
* This is called by config.set / config.apply / config.patch before writing,
|
||||||
* so that credentials survive a Web UI round-trip unmodified.
|
* 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<string>,
|
||||||
|
prefix: string,
|
||||||
|
hints: ConfigUiHints,
|
||||||
|
): unknown {
|
||||||
if (incoming === null || incoming === undefined) {
|
if (incoming === null || incoming === undefined) {
|
||||||
return incoming;
|
return incoming;
|
||||||
}
|
}
|
||||||
@@ -164,8 +391,27 @@ export function restoreRedactedValues(incoming: unknown, original: unknown): unk
|
|||||||
return incoming;
|
return incoming;
|
||||||
}
|
}
|
||||||
if (Array.isArray(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 : [];
|
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 =
|
const orig =
|
||||||
original && typeof original === "object" && !Array.isArray(original)
|
original && typeof original === "object" && !Array.isArray(original)
|
||||||
@@ -173,15 +419,101 @@ export function restoreRedactedValues(incoming: unknown, original: unknown): unk
|
|||||||
: {};
|
: {};
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
|
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
|
||||||
if (isSensitiveKey(key) && value === REDACTED_SENTINEL) {
|
result[key] = value;
|
||||||
if (!(key in orig)) {
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
throw new Error(
|
const wildcardPath = prefix ? `${prefix}.*` : "*";
|
||||||
`config write rejected: "${key}" is redacted; set an explicit value instead of ${REDACTED_SENTINEL}`,
|
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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
|
||||||
|
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) {
|
} else if (typeof value === "object" && value !== null) {
|
||||||
result[key] = restoreRedactedValues(value, orig[key]);
|
result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
|
||||||
} else {
|
} else {
|
||||||
result[key] = value;
|
result[key] = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,355 +1,4 @@
|
|||||||
export const GROUP_LABELS: Record<string, string> = {
|
import { IRC_FIELD_HELP } from "./schema.irc.js";
|
||||||
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<string, number> = {
|
|
||||||
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<string, string> = {
|
|
||||||
"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",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FIELD_HELP: Record<string, string> = {
|
export const FIELD_HELP: Record<string, string> = {
|
||||||
"meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.",
|
"meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.",
|
||||||
@@ -511,7 +160,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"agents.defaults.memorySearch.remote.headers":
|
"agents.defaults.memorySearch.remote.headers":
|
||||||
"Extra headers for remote embeddings (merged; remote overrides OpenAI headers).",
|
"Extra headers for remote embeddings (merged; remote overrides OpenAI headers).",
|
||||||
"agents.defaults.memorySearch.remote.batch.enabled":
|
"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":
|
"agents.defaults.memorySearch.remote.batch.wait":
|
||||||
"Wait for batch completion when indexing (default: true).",
|
"Wait for batch completion when indexing (default: true).",
|
||||||
"agents.defaults.memorySearch.remote.batch.concurrency":
|
"agents.defaults.memorySearch.remote.batch.concurrency":
|
||||||
@@ -632,8 +281,6 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
|
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
|
||||||
"commands.ownerAllowFrom":
|
"commands.ownerAllowFrom":
|
||||||
"Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.",
|
"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":
|
"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).',
|
'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":
|
"session.identityLinks":
|
||||||
@@ -654,6 +301,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"Allow iMessage to write config in response to channel events/commands (default: true).",
|
"Allow iMessage to write config in response to channel events/commands (default: true).",
|
||||||
"channels.msteams.configWrites":
|
"channels.msteams.configWrites":
|
||||||
"Allow Microsoft Teams to write config in response to channel events/commands (default: true).",
|
"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.native": 'Override native commands for Discord (bool or "auto").',
|
||||||
"channels.discord.commands.nativeSkills":
|
"channels.discord.commands.nativeSkills":
|
||||||
'Override native skill commands for Discord (bool or "auto").',
|
'Override native skill commands for Discord (bool or "auto").',
|
||||||
@@ -722,20 +370,3 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"channels.slack.dm.policy":
|
"channels.slack.dm.policy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FIELD_PLACEHOLDERS: Record<string, string> = {
|
|
||||||
"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));
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+108
-672
@@ -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 = {
|
export type ConfigUiHint = {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -69,674 +75,6 @@ const GROUP_ORDER: Record<string, number> = {
|
|||||||
logging: 900,
|
logging: 900,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
|
||||||
"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<string, string> = {
|
|
||||||
"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/<id>).",
|
|
||||||
"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<string, string> = {
|
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||||
"gateway.remote.url": "ws://host:18789",
|
"gateway.remote.url": "ws://host:18789",
|
||||||
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
|
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
|
||||||
@@ -748,10 +86,31 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
|
|||||||
"agents.list[].identity.avatar": "avatars/openclaw.png",
|
"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];
|
const SENSITIVE_PATTERNS = [/token$/i, /password/i, /secret/i, /api.?key/i];
|
||||||
|
|
||||||
function isSensitiveConfigPath(path: string): boolean {
|
export function isSensitiveConfigPath(path: string): boolean {
|
||||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
|
return (
|
||||||
|
!Array.from(SENSITIVE_KEY_WHITELIST).some((suffix) => path.endsWith(suffix)) &&
|
||||||
|
SENSITIVE_PATTERNS.some((pattern) => pattern.test(path))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBaseHints(): ConfigUiHints {
|
export function buildBaseHints(): ConfigUiHints {
|
||||||
@@ -778,12 +137,89 @@ export function buildBaseHints(): ConfigUiHints {
|
|||||||
return hints;
|
return hints;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applySensitiveHints(hints: ConfigUiHints): ConfigUiHints {
|
export function applySensitiveHints(
|
||||||
|
hints: ConfigUiHints,
|
||||||
|
allowedKeys?: ReadonlySet<string>,
|
||||||
|
): ConfigUiHints {
|
||||||
const next = { ...hints };
|
const next = { ...hints };
|
||||||
for (const key of Object.keys(next)) {
|
for (const key of Object.keys(next)) {
|
||||||
|
if (allowedKeys && !allowedKeys.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (next[key]?.sensitive !== undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (isSensitiveConfigPath(key)) {
|
if (isSensitiveConfigPath(key)) {
|
||||||
next[key] = { ...next[key], sensitive: true };
|
next[key] = { ...next[key], sensitive: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next;
|
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<string, unknown>).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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { IRC_FIELD_LABELS } from "./schema.irc.js";
|
||||||
|
|
||||||
|
export const FIELD_LABELS: Record<string, string> = {
|
||||||
|
"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",
|
||||||
|
};
|
||||||
@@ -36,6 +36,21 @@ describe("config schema", () => {
|
|||||||
expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.sensitive).toBe(true);
|
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", () => {
|
it("merges plugin + channel schemas", () => {
|
||||||
const res = buildConfigSchema({
|
const res = buildConfigSchema({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
+33
-7
@@ -1,7 +1,7 @@
|
|||||||
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||||
import { VERSION } from "../version.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";
|
import { OpenClawSchema } from "./zod-schema.js";
|
||||||
|
|
||||||
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||||
@@ -88,6 +88,28 @@ export type ChannelUiMetadata = {
|
|||||||
configUiHints?: Record<string, ConfigUiHint>;
|
configUiHints?: Record<string, ConfigUiHint>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function collectExtensionHintKeys(
|
||||||
|
hints: ConfigUiHints,
|
||||||
|
plugins: PluginUiMetadata[],
|
||||||
|
channels: ChannelUiMetadata[],
|
||||||
|
): Set<string> {
|
||||||
|
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 {
|
function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): ConfigUiHints {
|
||||||
const next: ConfigUiHints = { ...hints };
|
const next: ConfigUiHints = { ...hints };
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
@@ -299,7 +321,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
|
|||||||
unrepresentable: "any",
|
unrepresentable: "any",
|
||||||
});
|
});
|
||||||
schema.title = "OpenClawConfig";
|
schema.title = "OpenClawConfig";
|
||||||
const hints = applySensitiveHints(buildBaseHints());
|
const hints = mapSensitivePaths(OpenClawSchema, "", buildBaseHints());
|
||||||
const next = {
|
const next = {
|
||||||
schema: stripChannelSchema(schema),
|
schema: stripChannelSchema(schema),
|
||||||
uiHints: hints,
|
uiHints: hints,
|
||||||
@@ -320,12 +342,16 @@ export function buildConfigSchema(params?: {
|
|||||||
if (plugins.length === 0 && channels.length === 0) {
|
if (plugins.length === 0 && channels.length === 0) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
const mergedHints = applySensitiveHints(
|
const mergedWithoutSensitiveHints = applyHeartbeatTargetHints(
|
||||||
applyHeartbeatTargetHints(
|
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
|
||||||
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
|
channels,
|
||||||
channels,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
const extensionHintKeys = collectExtensionHintKeys(
|
||||||
|
mergedWithoutSensitiveHints,
|
||||||
|
plugins,
|
||||||
|
channels,
|
||||||
|
);
|
||||||
|
const mergedHints = applySensitiveHints(mergedWithoutSensitiveHints, extensionHintKeys);
|
||||||
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
|
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ToolsLinksSchema,
|
ToolsLinksSchema,
|
||||||
ToolsMediaSchema,
|
ToolsMediaSchema,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
|
import { sensitive } from "./zod-schema.sensitive.js";
|
||||||
|
|
||||||
export const HeartbeatSchema = z
|
export const HeartbeatSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -172,13 +173,13 @@ export const ToolsWebSearchSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok")]).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(),
|
maxResults: z.number().int().positive().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
cacheTtlMinutes: z.number().nonnegative().optional(),
|
cacheTtlMinutes: z.number().nonnegative().optional(),
|
||||||
perplexity: z
|
perplexity: z
|
||||||
.object({
|
.object({
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional().register(sensitive),
|
||||||
baseUrl: z.string().optional(),
|
baseUrl: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
})
|
})
|
||||||
@@ -186,7 +187,7 @@ export const ToolsWebSearchSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
grok: z
|
grok: z
|
||||||
.object({
|
.object({
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional().register(sensitive),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
inlineCitations: z.boolean().optional(),
|
inlineCitations: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
@@ -332,7 +333,7 @@ export const MemorySearchSchema = z
|
|||||||
remote: z
|
remote: z
|
||||||
.object({
|
.object({
|
||||||
baseUrl: z.string().optional(),
|
baseUrl: z.string().optional(),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional().register(sensitive),
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
batch: z
|
batch: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isSafeExecutableValue } from "../infra/exec-safety.js";
|
import { isSafeExecutableValue } from "../infra/exec-safety.js";
|
||||||
|
import { sensitive } from "./zod-schema.sensitive.js";
|
||||||
|
|
||||||
export const ModelApiSchema = z.union([
|
export const ModelApiSchema = z.union([
|
||||||
z.literal("openai-completions"),
|
z.literal("openai-completions"),
|
||||||
@@ -48,7 +49,7 @@ export const ModelDefinitionSchema = z
|
|||||||
export const ModelProviderSchema = z
|
export const ModelProviderSchema = z
|
||||||
.object({
|
.object({
|
||||||
baseUrl: z.string().min(1),
|
baseUrl: z.string().min(1),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional().register(sensitive),
|
||||||
auth: z
|
auth: z
|
||||||
.union([z.literal("api-key"), z.literal("aws-sdk"), z.literal("oauth"), z.literal("token")])
|
.union([z.literal("api-key"), z.literal("aws-sdk"), z.literal("oauth"), z.literal("token")])
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -180,7 +181,7 @@ export const TtsConfigSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
elevenlabs: z
|
elevenlabs: z
|
||||||
.object({
|
.object({
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional().register(sensitive),
|
||||||
baseUrl: z.string().optional(),
|
baseUrl: z.string().optional(),
|
||||||
voiceId: z.string().optional(),
|
voiceId: z.string().optional(),
|
||||||
modelId: z.string().optional(),
|
modelId: z.string().optional(),
|
||||||
@@ -202,7 +203,7 @@ export const TtsConfigSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
openai: z
|
openai: z
|
||||||
.object({
|
.object({
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional().register(sensitive),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
voice: z.string().optional(),
|
voice: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { sensitive } from "./zod-schema.sensitive.js";
|
||||||
|
|
||||||
export const HookMappingSchema = z
|
export const HookMappingSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -13,7 +14,7 @@ export const HookMappingSchema = z
|
|||||||
wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(),
|
wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
agentId: z.string().optional(),
|
agentId: z.string().optional(),
|
||||||
sessionKey: z.string().optional(),
|
sessionKey: z.string().optional().register(sensitive),
|
||||||
messageTemplate: z.string().optional(),
|
messageTemplate: z.string().optional(),
|
||||||
textTemplate: z.string().optional(),
|
textTemplate: z.string().optional(),
|
||||||
deliver: z.boolean().optional(),
|
deliver: z.boolean().optional(),
|
||||||
@@ -98,7 +99,7 @@ export const HooksGmailSchema = z
|
|||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
topic: z.string().optional(),
|
topic: z.string().optional(),
|
||||||
subscription: z.string().optional(),
|
subscription: z.string().optional(),
|
||||||
pushToken: z.string().optional(),
|
pushToken: z.string().optional().register(sensitive),
|
||||||
hookUrl: z.string().optional(),
|
hookUrl: z.string().optional(),
|
||||||
includeBody: z.boolean().optional(),
|
includeBody: z.boolean().optional(),
|
||||||
maxBytes: z.number().int().positive().optional(),
|
maxBytes: z.number().int().positive().optional(),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
RetryConfigSchema,
|
RetryConfigSchema,
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
|
import { sensitive } from "./zod-schema.sensitive.js";
|
||||||
|
|
||||||
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional().register(sensitive),
|
||||||
tokenFile: z.string().optional(),
|
tokenFile: z.string().optional(),
|
||||||
replyToMode: ReplyToModeSchema.optional(),
|
replyToMode: ReplyToModeSchema.optional(),
|
||||||
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
|
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
|
||||||
@@ -124,7 +125,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
proxy: z.string().optional(),
|
proxy: z.string().optional(),
|
||||||
webhookUrl: z.string().optional(),
|
webhookUrl: z.string().optional(),
|
||||||
webhookSecret: z.string().optional(),
|
webhookSecret: z.string().optional().register(sensitive),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
actions: z
|
actions: z
|
||||||
.object({
|
.object({
|
||||||
@@ -263,7 +264,7 @@ export const DiscordAccountSchema = z
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional().register(sensitive),
|
||||||
allowBots: z.boolean().optional(),
|
allowBots: z.boolean().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
@@ -324,7 +325,7 @@ export const DiscordAccountSchema = z
|
|||||||
pluralkit: z
|
pluralkit: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional().register(sensitive),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -463,16 +464,16 @@ export const SlackAccountSchema = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
mode: z.enum(["socket", "http"]).optional(),
|
mode: z.enum(["socket", "http"]).optional(),
|
||||||
signingSecret: z.string().optional(),
|
signingSecret: z.string().optional().register(sensitive),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional().register(sensitive),
|
||||||
appToken: z.string().optional(),
|
appToken: z.string().optional().register(sensitive),
|
||||||
userToken: z.string().optional(),
|
userToken: z.string().optional().register(sensitive),
|
||||||
userTokenReadOnly: z.boolean().optional().default(true),
|
userTokenReadOnly: z.boolean().optional().default(true),
|
||||||
allowBots: z.boolean().optional(),
|
allowBots: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
@@ -521,7 +522,7 @@ export const SlackAccountSchema = z
|
|||||||
|
|
||||||
export const SlackConfigSchema = SlackAccountSchema.extend({
|
export const SlackConfigSchema = SlackAccountSchema.extend({
|
||||||
mode: z.enum(["socket", "http"]).optional().default("socket"),
|
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"),
|
webhookPath: z.string().optional().default("/slack/events"),
|
||||||
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
|
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
|
||||||
}).superRefine((value, ctx) => {
|
}).superRefine((value, ctx) => {
|
||||||
@@ -641,7 +642,7 @@ export const IrcNickServSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
service: z.string().optional(),
|
service: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional().register(sensitive),
|
||||||
passwordFile: z.string().optional(),
|
passwordFile: z.string().optional(),
|
||||||
register: z.boolean().optional(),
|
register: z.boolean().optional(),
|
||||||
registerEmail: z.string().optional(),
|
registerEmail: z.string().optional(),
|
||||||
@@ -661,7 +662,7 @@ export const IrcAccountSchemaBase = z
|
|||||||
nick: z.string().optional(),
|
nick: z.string().optional(),
|
||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
realname: z.string().optional(),
|
realname: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional().register(sensitive),
|
||||||
passwordFile: z.string().optional(),
|
passwordFile: z.string().optional(),
|
||||||
nickserv: IrcNickServSchema.optional(),
|
nickserv: IrcNickServSchema.optional(),
|
||||||
channels: z.array(z.string()).optional(),
|
channels: z.array(z.string()).optional(),
|
||||||
@@ -822,7 +823,7 @@ export const BlueBubblesAccountSchemaBase = z
|
|||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
serverUrl: z.string().optional(),
|
serverUrl: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional().register(sensitive),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
allowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
|
allowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
|
||||||
@@ -893,7 +894,7 @@ export const MSTeamsConfigSchema = z
|
|||||||
markdown: MarkdownConfigSchema,
|
markdown: MarkdownConfigSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
appId: z.string().optional(),
|
appId: z.string().optional(),
|
||||||
appPassword: z.string().optional(),
|
appPassword: z.string().optional().register(sensitive),
|
||||||
tenantId: z.string().optional(),
|
tenantId: z.string().optional(),
|
||||||
webhook: z
|
webhook: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -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<undefined, z.ZodType>();
|
||||||
@@ -5,6 +5,7 @@ import { ApprovalsSchema } from "./zod-schema.approvals.js";
|
|||||||
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
|
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
|
||||||
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
|
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
|
||||||
import { ChannelsSchema } from "./zod-schema.providers.js";
|
import { ChannelsSchema } from "./zod-schema.providers.js";
|
||||||
|
import { sensitive } from "./zod-schema.sensitive.js";
|
||||||
import {
|
import {
|
||||||
CommandsSchema,
|
CommandsSchema,
|
||||||
MessagesSchema,
|
MessagesSchema,
|
||||||
@@ -301,7 +302,7 @@ export const OpenClawSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional().register(sensitive),
|
||||||
defaultSessionKey: z.string().optional(),
|
defaultSessionKey: z.string().optional(),
|
||||||
allowRequestSessionKey: z.boolean().optional(),
|
allowRequestSessionKey: z.boolean().optional(),
|
||||||
allowedSessionKeyPrefixes: z.array(z.string()).optional(),
|
allowedSessionKeyPrefixes: z.array(z.string()).optional(),
|
||||||
@@ -365,7 +366,7 @@ export const OpenClawSchema = z
|
|||||||
voiceAliases: z.record(z.string(), z.string()).optional(),
|
voiceAliases: z.record(z.string(), z.string()).optional(),
|
||||||
modelId: z.string().optional(),
|
modelId: z.string().optional(),
|
||||||
outputFormat: z.string().optional(),
|
outputFormat: z.string().optional(),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional().register(sensitive),
|
||||||
interruptOnSpeech: z.boolean().optional(),
|
interruptOnSpeech: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -397,8 +398,8 @@ export const OpenClawSchema = z
|
|||||||
auth: z
|
auth: z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional().register(sensitive),
|
||||||
password: z.string().optional(),
|
password: z.string().optional().register(sensitive),
|
||||||
allowTailscale: z.boolean().optional(),
|
allowTailscale: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -422,8 +423,8 @@ export const OpenClawSchema = z
|
|||||||
.object({
|
.object({
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
|
transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional().register(sensitive),
|
||||||
password: z.string().optional(),
|
password: z.string().optional().register(sensitive),
|
||||||
tlsFingerprint: z.string().optional(),
|
tlsFingerprint: z.string().optional(),
|
||||||
sshTarget: z.string().optional(),
|
sshTarget: z.string().optional(),
|
||||||
sshIdentity: z.string().optional(),
|
sshIdentity: z.string().optional(),
|
||||||
@@ -554,7 +555,7 @@ export const OpenClawSchema = z
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional().register(sensitive),
|
||||||
env: z.record(z.string(), z.string()).optional(),
|
env: z.record(z.string(), z.string()).optional(),
|
||||||
config: z.record(z.string(), z.unknown()).optional(),
|
config: z.record(z.string(), z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
redactConfigSnapshot,
|
redactConfigSnapshot,
|
||||||
restoreRedactedValues,
|
restoreRedactedValues,
|
||||||
} from "../../config/redact-snapshot.js";
|
} from "../../config/redact-snapshot.js";
|
||||||
import { buildConfigSchema } from "../../config/schema.js";
|
import { buildConfigSchema, type ConfigSchemaResponse } from "../../config/schema.js";
|
||||||
import {
|
import {
|
||||||
formatDoctorNonInteractiveHint,
|
formatDoctorNonInteractiveHint,
|
||||||
type RestartSentinelPayload,
|
type RestartSentinelPayload,
|
||||||
@@ -91,6 +91,41 @@ function requireConfigBaseHash(
|
|||||||
return true;
|
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 = {
|
export const configHandlers: GatewayRequestHandlers = {
|
||||||
"config.get": async ({ params, respond }) => {
|
"config.get": async ({ params, respond }) => {
|
||||||
if (!validateConfigGetParams(params)) {
|
if (!validateConfigGetParams(params)) {
|
||||||
@@ -105,7 +140,8 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
respond(true, redactConfigSnapshot(snapshot), undefined);
|
const schema = loadSchemaWithPlugins();
|
||||||
|
respond(true, redactConfigSnapshot(snapshot, schema.uiHints), undefined);
|
||||||
},
|
},
|
||||||
"config.schema": ({ params, respond }) => {
|
"config.schema": ({ params, respond }) => {
|
||||||
if (!validateConfigSchemaParams(params)) {
|
if (!validateConfigSchemaParams(params)) {
|
||||||
@@ -119,35 +155,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cfg = loadConfig();
|
respond(true, loadSchemaWithPlugins(), undefined);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
"config.set": async ({ params, respond }) => {
|
"config.set": async ({ params, respond }) => {
|
||||||
if (!validateConfigSetParams(params)) {
|
if (!validateConfigSetParams(params)) {
|
||||||
@@ -179,7 +187,17 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
|
||||||
return;
|
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) {
|
if (!validated.ok) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -190,27 +208,13 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let restored: typeof validated.config;
|
await writeConfigFile(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);
|
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
path: CONFIG_PATH,
|
path: CONFIG_PATH,
|
||||||
config: redactConfigObject(restored),
|
config: redactConfigObject(validated.config, schemaSet.uiHints),
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -269,19 +273,21 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
|
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
|
||||||
let restoredMerge: unknown;
|
const schemaPatch = loadSchemaWithPlugins();
|
||||||
try {
|
const restoredMerge = restoreRedactedValues(merged, snapshot.config, schemaPatch.uiHints);
|
||||||
restoredMerge = restoreRedactedValues(merged, snapshot.config);
|
if (!restoredMerge.ok) {
|
||||||
} catch (err) {
|
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)),
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
restoredMerge.humanReadableMessage ?? "invalid config",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const migrated = applyLegacyMigrations(restoredMerge);
|
const migrated = applyLegacyMigrations(restoredMerge.result);
|
||||||
const resolved = migrated.next ?? restoredMerge;
|
const resolved = migrated.next ?? restoredMerge.result;
|
||||||
const validated = validateConfigObjectWithPlugins(resolved);
|
const validated = validateConfigObjectWithPlugins(resolved);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
respond(
|
respond(
|
||||||
@@ -336,7 +342,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
path: CONFIG_PATH,
|
path: CONFIG_PATH,
|
||||||
config: redactConfigObject(validated.config),
|
config: redactConfigObject(validated.config, schemaPatch.uiHints),
|
||||||
restart,
|
restart,
|
||||||
sentinel: {
|
sentinel: {
|
||||||
path: sentinelPath,
|
path: sentinelPath,
|
||||||
@@ -379,7 +385,17 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
|
||||||
return;
|
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) {
|
if (!validated.ok) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -390,21 +406,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let restoredApply: typeof validated.config;
|
await writeConfigFile(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);
|
|
||||||
|
|
||||||
const sessionKey =
|
const sessionKey =
|
||||||
typeof (params as { sessionKey?: unknown }).sessionKey === "string"
|
typeof (params as { sessionKey?: unknown }).sessionKey === "string"
|
||||||
@@ -447,7 +449,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
path: CONFIG_PATH,
|
path: CONFIG_PATH,
|
||||||
config: redactConfigObject(restoredApply),
|
config: redactConfigObject(validated.config, schemaApply.uiHints),
|
||||||
restart,
|
restart,
|
||||||
sentinel: {
|
sentinel: {
|
||||||
path: sentinelPath,
|
path: sentinelPath,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
defaultValue,
|
defaultValue,
|
||||||
hintForPath,
|
hintForPath,
|
||||||
humanize,
|
humanize,
|
||||||
isSensitivePath,
|
|
||||||
pathKey,
|
pathKey,
|
||||||
schemaType,
|
schemaType,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
@@ -307,7 +306,8 @@ function renderTextInput(params: {
|
|||||||
const hint = hintForPath(path, hints);
|
const hint = hintForPath(path, hints);
|
||||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||||
const help = hint?.help ?? schema.description;
|
const help = hint?.help ?? schema.description;
|
||||||
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
|
const isSensitive =
|
||||||
|
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
|
||||||
const placeholder =
|
const placeholder =
|
||||||
hint?.placeholder ??
|
hint?.placeholder ??
|
||||||
// oxlint-disable typescript/no-base-to-string
|
// oxlint-disable typescript/no-base-to-string
|
||||||
|
|||||||
@@ -92,14 +92,3 @@ export function humanize(raw: string) {
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.replace(/^./, (m) => m.toUpperCase());
|
.replace(/^./, (m) => m.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSensitivePath(path: Array<string | number>): boolean {
|
|
||||||
const key = pathKey(path).toLowerCase();
|
|
||||||
return (
|
|
||||||
key.includes("token") ||
|
|
||||||
key.includes("password") ||
|
|
||||||
key.includes("secret") ||
|
|
||||||
key.includes("apikey") ||
|
|
||||||
key.endsWith("key")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user