mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 10:02:04 +00:00
1070 lines
39 KiB
TypeScript
1070 lines
39 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
REDACTED_SENTINEL,
|
|
redactConfigSnapshot,
|
|
restoreRedactedValues as restoreRedactedValues_orig,
|
|
} from "./redact-snapshot.js";
|
|
import { __test__ } from "./schema.hints.js";
|
|
import type { ConfigUiHints } from "./schema.js";
|
|
import type { ConfigFileSnapshot } from "./types.openclaw.js";
|
|
import { OpenClawSchema } from "./zod-schema.js";
|
|
|
|
const { mapSensitivePaths } = __test__;
|
|
|
|
type TestSnapshot<TConfig extends Record<string, unknown>> = ConfigFileSnapshot & {
|
|
parsed: TConfig;
|
|
resolved: TConfig;
|
|
config: TConfig;
|
|
};
|
|
|
|
function makeSnapshot<TConfig extends Record<string, unknown>>(
|
|
config: TConfig,
|
|
raw?: string,
|
|
): TestSnapshot<TConfig> {
|
|
return {
|
|
path: "/home/user/.openclaw/config.json5",
|
|
exists: true,
|
|
raw: raw ?? JSON.stringify(config),
|
|
parsed: config,
|
|
resolved: config as ConfigFileSnapshot["resolved"],
|
|
valid: true,
|
|
config: config as ConfigFileSnapshot["config"],
|
|
hash: "abc123",
|
|
issues: [],
|
|
warnings: [],
|
|
legacyIssues: [],
|
|
} as unknown as TestSnapshot<TConfig>;
|
|
}
|
|
|
|
function restoreRedactedValues<TOriginal>(
|
|
incoming: unknown,
|
|
original: TOriginal,
|
|
hints?: ConfigUiHints,
|
|
): TOriginal {
|
|
var result = restoreRedactedValues_orig(incoming, original, hints);
|
|
expect(result.ok).toBe(true);
|
|
return result.result as TOriginal;
|
|
}
|
|
|
|
function expectNestedLevelPairValue(
|
|
source: Record<string, Record<string, Record<string, unknown>>>,
|
|
field: string,
|
|
expected: readonly [unknown, unknown],
|
|
): void {
|
|
const values = source.nested.level[field] as unknown[];
|
|
expect(values[0]).toBe(expected[0]);
|
|
expect(values[1]).toBe(expected[1]);
|
|
}
|
|
|
|
function expectGatewayAuthFieldValue(
|
|
result: ReturnType<typeof redactConfigSnapshot>,
|
|
field: "token" | "password",
|
|
expected: string,
|
|
): void {
|
|
const gateway = result.config.gateway as Record<string, Record<string, string>>;
|
|
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
|
|
expect(gateway.auth[field]).toBe(expected);
|
|
expect(resolved.gateway.auth[field]).toBe(expected);
|
|
}
|
|
|
|
describe("redactConfigSnapshot", () => {
|
|
it("redacts common secret field patterns across config sections", () => {
|
|
const snapshot = makeSnapshot({
|
|
gateway: {
|
|
auth: {
|
|
token: "my-super-secret-gateway-token-value",
|
|
password: "super-secret-password-value-here",
|
|
},
|
|
},
|
|
channels: {
|
|
telegram: {
|
|
botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef",
|
|
webhookSecret: "telegram-webhook-secret-value-1234",
|
|
},
|
|
slack: {
|
|
botToken: "fake-slack-bot-token-placeholder-value",
|
|
signingSecret: "slack-signing-secret-value-1234",
|
|
token: "secret-slack-token-value-here",
|
|
},
|
|
feishu: { appSecret: "feishu-app-secret-value-here-1234" },
|
|
},
|
|
models: {
|
|
providers: {
|
|
openai: { apiKey: "sk-proj-abcdef1234567890ghij", baseUrl: "https://api.openai.com" },
|
|
},
|
|
},
|
|
shortSecret: { token: "short" },
|
|
});
|
|
const result = redactConfigSnapshot(snapshot);
|
|
const cfg = result.config as typeof snapshot.config;
|
|
|
|
expect(cfg.gateway.auth.token).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.gateway.auth.password).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.channels.telegram.botToken).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.channels.telegram.webhookSecret).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.channels.slack.botToken).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.channels.slack.signingSecret).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.channels.slack.token).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.channels.feishu.appSecret).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.models.providers.openai.apiKey).toBe(REDACTED_SENTINEL);
|
|
expect(cfg.models.providers.openai.baseUrl).toBe("https://api.openai.com");
|
|
expect(cfg.shortSecret.token).toBe(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("redacts googlechat serviceAccount object payloads", () => {
|
|
const snapshot = makeSnapshot({
|
|
channels: {
|
|
googlechat: {
|
|
serviceAccount: {
|
|
type: "service_account",
|
|
client_email: "bot@example.iam.gserviceaccount.com",
|
|
private_key: "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = redactConfigSnapshot(snapshot);
|
|
const channels = result.config.channels as Record<string, Record<string, unknown>>;
|
|
expect(channels.googlechat.serviceAccount).toBe(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("redacts object-valued apiKey refs in model providers", () => {
|
|
const snapshot = makeSnapshot({
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
baseUrl: "https://api.openai.com",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = redactConfigSnapshot(snapshot);
|
|
const models = result.config.models as Record<string, Record<string, Record<string, unknown>>>;
|
|
expect(models.providers.openai.apiKey).toEqual({
|
|
source: REDACTED_SENTINEL,
|
|
provider: REDACTED_SENTINEL,
|
|
id: REDACTED_SENTINEL,
|
|
});
|
|
expect(models.providers.openai.baseUrl).toBe("https://api.openai.com");
|
|
});
|
|
|
|
it("preserves non-sensitive fields", () => {
|
|
const snapshot = makeSnapshot({
|
|
ui: { seamColor: "#0088cc" },
|
|
gateway: { port: 18789 },
|
|
models: { providers: { openai: { baseUrl: "https://api.openai.com" } } },
|
|
});
|
|
const result = redactConfigSnapshot(snapshot);
|
|
expect(result.config).toEqual(snapshot.config);
|
|
});
|
|
|
|
it("does not redact maxTokens-style fields", () => {
|
|
const snapshot = makeSnapshot({
|
|
maxTokens: 16384,
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
models: [
|
|
{
|
|
id: "gpt-5",
|
|
maxTokens: 65536,
|
|
contextTokens: 200000,
|
|
maxTokensField: "max_completion_tokens",
|
|
},
|
|
],
|
|
apiKey: "sk-proj-abcdef1234567890ghij",
|
|
accessToken: "access-token-value-1234567890",
|
|
maxTokens: 8192,
|
|
maxOutputTokens: 4096,
|
|
maxCompletionTokens: 2048,
|
|
contextTokens: 128000,
|
|
tokenCount: 500,
|
|
tokenLimit: 100000,
|
|
tokenBudget: 50000,
|
|
},
|
|
},
|
|
},
|
|
gateway: { auth: { token: "secret-gateway-token-value" } },
|
|
});
|
|
|
|
const result = redactConfigSnapshot(snapshot);
|
|
expect((result.config as Record<string, unknown>).maxTokens).toBe(16384);
|
|
const models = result.config.models as Record<string, unknown>;
|
|
const providerList = ((
|
|
(models.providers as Record<string, unknown>).openai as Record<string, unknown>
|
|
).models ?? []) as Array<Record<string, unknown>>;
|
|
expect(providerList[0]?.maxTokens).toBe(65536);
|
|
expect(providerList[0]?.contextTokens).toBe(200000);
|
|
expect(providerList[0]?.maxTokensField).toBe("max_completion_tokens");
|
|
|
|
const providers = (models.providers as Record<string, Record<string, unknown>>) ?? {};
|
|
expect(providers.openai.apiKey).toBe(REDACTED_SENTINEL);
|
|
expect(providers.openai.accessToken).toBe(REDACTED_SENTINEL);
|
|
expect(providers.openai.maxTokens).toBe(8192);
|
|
expect(providers.openai.maxOutputTokens).toBe(4096);
|
|
expect(providers.openai.maxCompletionTokens).toBe(2048);
|
|
expect(providers.openai.contextTokens).toBe(128000);
|
|
expect(providers.openai.tokenCount).toBe(500);
|
|
expect(providers.openai.tokenLimit).toBe(100000);
|
|
expect(providers.openai.tokenBudget).toBe(50000);
|
|
|
|
const gw = result.config.gateway as Record<string, Record<string, string>>;
|
|
expect(gw.auth.token).toBe(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("does not redact passwordFile path fields", () => {
|
|
const snapshot = makeSnapshot({
|
|
channels: {
|
|
irc: {
|
|
passwordFile: "/etc/openclaw/irc-password.txt",
|
|
nickserv: {
|
|
passwordFile: "/etc/openclaw/nickserv-password.txt",
|
|
password: "super-secret-nickserv-password",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = redactConfigSnapshot(snapshot);
|
|
const channels = result.config.channels as Record<string, Record<string, unknown>>;
|
|
const irc = channels.irc;
|
|
const nickserv = irc.nickserv as Record<string, unknown>;
|
|
|
|
expect(irc.passwordFile).toBe("/etc/openclaw/irc-password.txt");
|
|
expect(nickserv.passwordFile).toBe("/etc/openclaw/nickserv-password.txt");
|
|
expect(nickserv.password).toBe(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("preserves hash unchanged", () => {
|
|
const snapshot = makeSnapshot({ gateway: { auth: { token: "secret-token-value-here" } } });
|
|
const result = redactConfigSnapshot(snapshot);
|
|
expect(result.hash).toBe("abc123");
|
|
});
|
|
|
|
it("redacts secrets in raw field via text-based redaction", () => {
|
|
const config = { token: "abcdef1234567890ghij" };
|
|
const raw = '{ "token": "abcdef1234567890ghij" }';
|
|
const snapshot = makeSnapshot(config, raw);
|
|
const result = redactConfigSnapshot(snapshot);
|
|
expect(result.raw).not.toContain("abcdef1234567890ghij");
|
|
expect(result.raw).toContain(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("redacts parsed and resolved objects", () => {
|
|
const snapshot = makeSnapshot({
|
|
channels: { discord: { token: "MTIzNDU2Nzg5MDEyMzQ1Njc4.GaBcDe.FgH" } },
|
|
gateway: { auth: { token: "supersecrettoken123456" } },
|
|
});
|
|
const result = redactConfigSnapshot(snapshot);
|
|
const parsed = result.parsed as Record<string, Record<string, Record<string, string>>>;
|
|
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
|
|
expect(parsed.channels.discord.token).toBe(REDACTED_SENTINEL);
|
|
expect(resolved.gateway.auth.token).toBe(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("handles null raw gracefully", () => {
|
|
const snapshot: ConfigFileSnapshot = {
|
|
path: "/test",
|
|
exists: false,
|
|
raw: null,
|
|
parsed: null,
|
|
resolved: {} as ConfigFileSnapshot["resolved"],
|
|
valid: false,
|
|
config: {} as ConfigFileSnapshot["config"],
|
|
issues: [],
|
|
warnings: [],
|
|
legacyIssues: [],
|
|
};
|
|
const result = redactConfigSnapshot(snapshot);
|
|
expect(result.raw).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", () => {
|
|
const snapshot = makeSnapshot({
|
|
channels: {
|
|
slack: {
|
|
accounts: {
|
|
workspace1: { botToken: "fake-workspace1-token-abcdefghij" },
|
|
workspace2: { appToken: "fake-workspace2-token-abcdefghij" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const result = redactConfigSnapshot(snapshot);
|
|
const channels = result.config.channels as Record<
|
|
string,
|
|
Record<string, Record<string, Record<string, string>>>
|
|
>;
|
|
expect(channels.slack.accounts.workspace1.botToken).toBe(REDACTED_SENTINEL);
|
|
expect(channels.slack.accounts.workspace2.appToken).toBe(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("redacts env vars that look like secrets", () => {
|
|
const snapshot = makeSnapshot({
|
|
env: {
|
|
vars: {
|
|
OPENAI_API_KEY: "sk-proj-1234567890abcdefghij",
|
|
NODE_ENV: "production",
|
|
},
|
|
},
|
|
});
|
|
const result = redactConfigSnapshot(snapshot);
|
|
const env = result.config.env as Record<string, Record<string, string>>;
|
|
// NODE_ENV is not sensitive, should be preserved
|
|
expect(env.vars.NODE_ENV).toBe("production");
|
|
expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("respects token-name redaction boundaries", () => {
|
|
const cases = [
|
|
{
|
|
name: "does not redact numeric tokens field",
|
|
snapshot: makeSnapshot({ memory: { tokens: 8192 } }),
|
|
assert: (config: Record<string, unknown>) => {
|
|
expect((config.memory as Record<string, unknown>).tokens).toBe(8192);
|
|
},
|
|
},
|
|
{
|
|
name: "does not redact softThresholdTokens",
|
|
snapshot: makeSnapshot({ compaction: { softThresholdTokens: 50000 } }),
|
|
assert: (config: Record<string, unknown>) => {
|
|
expect((config.compaction as Record<string, unknown>).softThresholdTokens).toBe(50000);
|
|
},
|
|
},
|
|
{
|
|
name: "does not redact string tokens field",
|
|
snapshot: makeSnapshot({ memory: { tokens: "should-not-be-redacted" } }),
|
|
assert: (config: Record<string, unknown>) => {
|
|
expect((config.memory as Record<string, unknown>).tokens).toBe("should-not-be-redacted");
|
|
},
|
|
},
|
|
{
|
|
name: "still redacts singular token field",
|
|
snapshot: makeSnapshot({
|
|
channels: { slack: { token: "secret-slack-token-value-here" } },
|
|
}),
|
|
assert: (config: Record<string, unknown>) => {
|
|
const channels = config.channels as Record<string, Record<string, string>>;
|
|
expect(channels.slack.token).toBe(REDACTED_SENTINEL);
|
|
},
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
const result = redactConfigSnapshot(testCase.snapshot);
|
|
testCase.assert(result.config as Record<string, unknown>);
|
|
}
|
|
});
|
|
|
|
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 config = result.config as typeof snapshot.config;
|
|
const custom = config.custom as Record<string, string>;
|
|
const resolved = result.resolved as Record<string, Record<string, string>>;
|
|
expect(custom.mySecret).toBe(REDACTED_SENTINEL);
|
|
expect(resolved.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);
|
|
const config = redacted.config as typeof snapshot.config;
|
|
expect(config.plugins.entries["voice-call"].config.apiToken).toBe(REDACTED_SENTINEL);
|
|
expect(config.plugins.entries["voice-call"].config.displayName).toBe("Voice call extension");
|
|
expect(config.channels["my-channel"].accessToken).toBe(REDACTED_SENTINEL);
|
|
expect(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);
|
|
const config = redacted.config as typeof snapshot.config;
|
|
expect(config.plugins.entries["voice-call"].config.apiToken).toBe("not-secret-on-purpose");
|
|
});
|
|
|
|
it("round-trips nested and array sensitivity cases", () => {
|
|
const customSecretValue = "this-is-a-custom-secret-value";
|
|
const buildNestedValuesSnapshot = () =>
|
|
makeSnapshot({
|
|
custom1: { anykey: { mySecret: customSecretValue } },
|
|
custom2: [{ mySecret: customSecretValue }],
|
|
});
|
|
const assertNestedValuesRoundTrip = ({
|
|
redacted,
|
|
restored,
|
|
}: {
|
|
redacted: Record<string, unknown>;
|
|
restored: Record<string, unknown>;
|
|
}) => {
|
|
const cfg = redacted as Record<string, Record<string, unknown>>;
|
|
const cfgCustom2 = cfg.custom2 as unknown as unknown[];
|
|
expect(cfgCustom2.length).toBeGreaterThan(0);
|
|
expect((cfg.custom1.anykey as Record<string, unknown>).mySecret).toBe(REDACTED_SENTINEL);
|
|
expect((cfgCustom2[0] as Record<string, unknown>).mySecret).toBe(REDACTED_SENTINEL);
|
|
|
|
const out = restored as Record<string, Record<string, unknown>>;
|
|
const outCustom2 = out.custom2 as unknown as unknown[];
|
|
expect(outCustom2.length).toBeGreaterThan(0);
|
|
expect((out.custom1.anykey as Record<string, unknown>).mySecret).toBe(customSecretValue);
|
|
expect((outCustom2[0] as Record<string, unknown>).mySecret).toBe(customSecretValue);
|
|
};
|
|
|
|
const cases: Array<{
|
|
name: string;
|
|
snapshot: TestSnapshot<Record<string, unknown>>;
|
|
hints?: ConfigUiHints;
|
|
assert: (params: {
|
|
redacted: Record<string, unknown>;
|
|
restored: Record<string, unknown>;
|
|
}) => void;
|
|
}> = [
|
|
{
|
|
name: "nested values (schema)",
|
|
snapshot: buildNestedValuesSnapshot(),
|
|
assert: assertNestedValuesRoundTrip,
|
|
},
|
|
{
|
|
name: "nested values (uiHints)",
|
|
hints: {
|
|
"custom1.*.mySecret": { sensitive: true },
|
|
"custom2[].mySecret": { sensitive: true },
|
|
},
|
|
snapshot: buildNestedValuesSnapshot(),
|
|
assert: assertNestedValuesRoundTrip,
|
|
},
|
|
{
|
|
name: "directly sensitive records and arrays",
|
|
snapshot: makeSnapshot({
|
|
custom: {
|
|
token: "this-is-a-custom-secret-value",
|
|
mySecret: "this-is-a-custom-secret-value",
|
|
},
|
|
token: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"],
|
|
}),
|
|
assert: ({ redacted, restored }) => {
|
|
const cfg = redacted;
|
|
const custom = cfg.custom as Record<string, unknown>;
|
|
expect(custom.token).toBe(REDACTED_SENTINEL);
|
|
expect(custom.mySecret).toBe(REDACTED_SENTINEL);
|
|
expect((cfg.token as unknown[])[0]).toBe(REDACTED_SENTINEL);
|
|
expect((cfg.token as unknown[])[1]).toBe(REDACTED_SENTINEL);
|
|
|
|
const out = restored;
|
|
const restoredCustom = out.custom as Record<string, unknown>;
|
|
expect(restoredCustom.token).toBe("this-is-a-custom-secret-value");
|
|
expect(restoredCustom.mySecret).toBe("this-is-a-custom-secret-value");
|
|
expect((out.token as unknown[])[0]).toBe("this-is-a-custom-secret-value");
|
|
expect((out.token as unknown[])[1]).toBe("this-is-a-custom-secret-value");
|
|
},
|
|
},
|
|
{
|
|
name: "directly sensitive records and arrays (uiHints)",
|
|
hints: {
|
|
"custom.*": { sensitive: true },
|
|
"customArray[]": { sensitive: true },
|
|
},
|
|
snapshot: makeSnapshot({
|
|
custom: {
|
|
anykey: "this-is-a-custom-secret-value",
|
|
mySecret: "this-is-a-custom-secret-value",
|
|
},
|
|
customArray: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"],
|
|
}),
|
|
assert: ({ redacted, restored }) => {
|
|
const cfg = redacted;
|
|
const custom = cfg.custom as Record<string, unknown>;
|
|
expect(custom.anykey).toBe(REDACTED_SENTINEL);
|
|
expect(custom.mySecret).toBe(REDACTED_SENTINEL);
|
|
expect((cfg.customArray as unknown[])[0]).toBe(REDACTED_SENTINEL);
|
|
expect((cfg.customArray as unknown[])[1]).toBe(REDACTED_SENTINEL);
|
|
|
|
const out = restored;
|
|
const restoredCustom = out.custom as Record<string, unknown>;
|
|
expect(restoredCustom.anykey).toBe("this-is-a-custom-secret-value");
|
|
expect(restoredCustom.mySecret).toBe("this-is-a-custom-secret-value");
|
|
expect((out.customArray as unknown[])[0]).toBe("this-is-a-custom-secret-value");
|
|
expect((out.customArray as unknown[])[1]).toBe("this-is-a-custom-secret-value");
|
|
},
|
|
},
|
|
{
|
|
name: "non-sensitive arrays remain unchanged",
|
|
hints: {
|
|
"custom[]": { sensitive: false },
|
|
},
|
|
snapshot: makeSnapshot({
|
|
harmless: ["this-is-a-custom-harmless-value", "this-is-a-custom-secret-looking-value"],
|
|
custom: ["this-is-a-custom-harmless-value", "this-is-a-custom-secret-value"],
|
|
}),
|
|
assert: ({ redacted, restored }) => {
|
|
const cfg = redacted;
|
|
expect((cfg.harmless as unknown[])[0]).toBe("this-is-a-custom-harmless-value");
|
|
expect((cfg.harmless as unknown[])[1]).toBe("this-is-a-custom-secret-looking-value");
|
|
expect((cfg.custom as unknown[])[0]).toBe("this-is-a-custom-harmless-value");
|
|
expect((cfg.custom as unknown[])[1]).toBe("this-is-a-custom-secret-value");
|
|
|
|
const out = restored;
|
|
expect((out.harmless as unknown[])[0]).toBe("this-is-a-custom-harmless-value");
|
|
expect((out.harmless as unknown[])[1]).toBe("this-is-a-custom-secret-looking-value");
|
|
expect((out.custom as unknown[])[0]).toBe("this-is-a-custom-harmless-value");
|
|
expect((out.custom as unknown[])[1]).toBe("this-is-a-custom-secret-value");
|
|
},
|
|
},
|
|
{
|
|
name: "deep schema-sensitive arrays and upstream-sensitive paths",
|
|
snapshot: makeSnapshot({
|
|
nested: {
|
|
level: {
|
|
token: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"],
|
|
harmless: ["value", "value"],
|
|
},
|
|
password: {
|
|
harmless: ["value", "value"],
|
|
},
|
|
},
|
|
}),
|
|
assert: ({ redacted, restored }) => {
|
|
const cfg = redacted as Record<string, Record<string, Record<string, unknown>>>;
|
|
expect((cfg.nested.level.token as unknown[])[0]).toBe(REDACTED_SENTINEL);
|
|
expect((cfg.nested.level.token as unknown[])[1]).toBe(REDACTED_SENTINEL);
|
|
expect((cfg.nested.level.harmless as unknown[])[0]).toBe("value");
|
|
expect((cfg.nested.level.harmless as unknown[])[1]).toBe("value");
|
|
expect((cfg.nested.password.harmless as unknown[])[0]).toBe(REDACTED_SENTINEL);
|
|
expect((cfg.nested.password.harmless as unknown[])[1]).toBe(REDACTED_SENTINEL);
|
|
|
|
const out = restored as Record<string, Record<string, Record<string, unknown>>>;
|
|
expect((out.nested.level.token as unknown[])[0]).toBe("this-is-a-custom-secret-value");
|
|
expect((out.nested.level.token as unknown[])[1]).toBe("this-is-a-custom-secret-value");
|
|
expect((out.nested.level.harmless as unknown[])[0]).toBe("value");
|
|
expect((out.nested.level.harmless as unknown[])[1]).toBe("value");
|
|
expect((out.nested.password.harmless as unknown[])[0]).toBe("value");
|
|
expect((out.nested.password.harmless as unknown[])[1]).toBe("value");
|
|
},
|
|
},
|
|
{
|
|
name: "deep non-string arrays on schema-sensitive paths remain unchanged",
|
|
snapshot: makeSnapshot({
|
|
nested: {
|
|
level: {
|
|
token: [42, 815],
|
|
},
|
|
},
|
|
}),
|
|
assert: ({ redacted, restored }) => {
|
|
const cfg = redacted as Record<string, Record<string, Record<string, unknown>>>;
|
|
expectNestedLevelPairValue(cfg, "token", [42, 815]);
|
|
|
|
const out = restored as Record<string, Record<string, Record<string, unknown>>>;
|
|
expectNestedLevelPairValue(out, "token", [42, 815]);
|
|
},
|
|
},
|
|
{
|
|
name: "deep arrays respect uiHints sensitivity",
|
|
hints: {
|
|
"nested.level.custom[]": { sensitive: true },
|
|
},
|
|
snapshot: makeSnapshot({
|
|
nested: {
|
|
level: {
|
|
custom: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"],
|
|
},
|
|
},
|
|
}),
|
|
assert: ({ redacted, restored }) => {
|
|
const cfg = redacted as Record<string, Record<string, Record<string, unknown>>>;
|
|
expect((cfg.nested.level.custom as unknown[])[0]).toBe(REDACTED_SENTINEL);
|
|
expect((cfg.nested.level.custom as unknown[])[1]).toBe(REDACTED_SENTINEL);
|
|
|
|
const out = restored as Record<string, Record<string, Record<string, unknown>>>;
|
|
expect((out.nested.level.custom as unknown[])[0]).toBe("this-is-a-custom-secret-value");
|
|
expect((out.nested.level.custom as unknown[])[1]).toBe("this-is-a-custom-secret-value");
|
|
},
|
|
},
|
|
{
|
|
name: "deep non-string arrays respect uiHints sensitivity",
|
|
hints: {
|
|
"nested.level.custom[]": { sensitive: true },
|
|
},
|
|
snapshot: makeSnapshot({
|
|
nested: {
|
|
level: {
|
|
custom: [42, 815],
|
|
},
|
|
},
|
|
}),
|
|
assert: ({ redacted, restored }) => {
|
|
const cfg = redacted as Record<string, Record<string, Record<string, unknown>>>;
|
|
expectNestedLevelPairValue(cfg, "custom", [42, 815]);
|
|
|
|
const out = restored as Record<string, Record<string, Record<string, unknown>>>;
|
|
expectNestedLevelPairValue(out, "custom", [42, 815]);
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
const redacted = redactConfigSnapshot(testCase.snapshot, testCase.hints);
|
|
const restored = restoreRedactedValues(
|
|
redacted.config,
|
|
testCase.snapshot.config,
|
|
testCase.hints,
|
|
);
|
|
testCase.assert({
|
|
redacted: redacted.config as Record<string, unknown>,
|
|
restored: restored as Record<string, unknown>,
|
|
});
|
|
}
|
|
});
|
|
|
|
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);
|
|
expectGatewayAuthFieldValue(result, "token", "not-actually-secret-value");
|
|
});
|
|
|
|
it("redacts sensitive-looking paths even when absent from uiHints (defense in depth)", () => {
|
|
const hints: ConfigUiHints = {
|
|
"some.other.path": { sensitive: true },
|
|
};
|
|
const snapshot = makeSnapshot({
|
|
gateway: { auth: { password: "not-in-hints-value" } },
|
|
});
|
|
const result = redactConfigSnapshot(snapshot, hints);
|
|
expectGatewayAuthFieldValue(result, "password", REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("redacts and restores dynamic env catchall secrets when uiHints miss the path", () => {
|
|
const hints: ConfigUiHints = {
|
|
"some.other.path": { sensitive: true },
|
|
};
|
|
const snapshot = makeSnapshot({
|
|
env: {
|
|
GROQ_API_KEY: "gsk-secret-123",
|
|
NODE_ENV: "production",
|
|
},
|
|
});
|
|
const redacted = redactConfigSnapshot(snapshot, hints);
|
|
const env = redacted.config.env as Record<string, string>;
|
|
expect(env.GROQ_API_KEY).toBe(REDACTED_SENTINEL);
|
|
expect(env.NODE_ENV).toBe("production");
|
|
|
|
const restored = restoreRedactedValues(redacted.config, snapshot.config, hints);
|
|
expect(restored.env.GROQ_API_KEY).toBe("gsk-secret-123");
|
|
expect(restored.env.NODE_ENV).toBe("production");
|
|
});
|
|
|
|
it("redacts and restores skills entry env secrets in dynamic record paths", () => {
|
|
const hints: ConfigUiHints = {
|
|
"some.other.path": { sensitive: true },
|
|
};
|
|
const snapshot = makeSnapshot({
|
|
skills: {
|
|
entries: {
|
|
web_search: {
|
|
env: {
|
|
GEMINI_API_KEY: "gemini-secret-456",
|
|
BRAVE_REGION: "us",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const redacted = redactConfigSnapshot(snapshot, hints);
|
|
const entry = (
|
|
redacted.config.skills as {
|
|
entries: Record<string, { env: Record<string, string> }>;
|
|
}
|
|
).entries.web_search;
|
|
expect(entry.env.GEMINI_API_KEY).toBe(REDACTED_SENTINEL);
|
|
expect(entry.env.BRAVE_REGION).toBe("us");
|
|
|
|
const restored = restoreRedactedValues(redacted.config, snapshot.config, hints);
|
|
expect(restored.skills.entries.web_search.env.GEMINI_API_KEY).toBe("gemini-secret-456");
|
|
expect(restored.skills.entries.web_search.env.BRAVE_REGION).toBe("us");
|
|
});
|
|
|
|
it("contract-covers dynamic catchall/record paths for redact+restore", () => {
|
|
const hints = mapSensitivePaths(OpenClawSchema, "", {});
|
|
const snapshot = makeSnapshot({
|
|
env: {
|
|
GROQ_API_KEY: "gsk-contract-123",
|
|
NODE_ENV: "production",
|
|
},
|
|
skills: {
|
|
entries: {
|
|
web_search: {
|
|
env: {
|
|
GEMINI_API_KEY: "gemini-contract-456",
|
|
BRAVE_REGION: "us",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
broadcast: {
|
|
apiToken: ["broadcast-secret-1", "broadcast-secret-2"],
|
|
channels: ["ops", "eng"],
|
|
},
|
|
});
|
|
|
|
const redacted = redactConfigSnapshot(snapshot, hints);
|
|
const config = redacted.config as {
|
|
env: Record<string, string>;
|
|
skills: { entries: Record<string, { env: Record<string, string> }> };
|
|
broadcast: Record<string, string[]>;
|
|
};
|
|
|
|
expect(config.env.GROQ_API_KEY).toBe(REDACTED_SENTINEL);
|
|
expect(config.env.NODE_ENV).toBe("production");
|
|
expect(config.skills.entries.web_search.env.GEMINI_API_KEY).toBe(REDACTED_SENTINEL);
|
|
expect(config.skills.entries.web_search.env.BRAVE_REGION).toBe("us");
|
|
expect(config.broadcast.apiToken).toEqual([REDACTED_SENTINEL, REDACTED_SENTINEL]);
|
|
expect(config.broadcast.channels).toEqual(["ops", "eng"]);
|
|
|
|
const restored = restoreRedactedValues(redacted.config, snapshot.config, hints);
|
|
expect(restored).toEqual(snapshot.config);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("restoreRedactedValues", () => {
|
|
it("restores sentinel values from original config", () => {
|
|
const incoming = {
|
|
gateway: { auth: { token: REDACTED_SENTINEL } },
|
|
};
|
|
const original = {
|
|
gateway: { auth: { token: "real-secret-token-value" } },
|
|
};
|
|
const result = restoreRedactedValues(incoming, original) as typeof incoming;
|
|
expect(result.gateway.auth.token).toBe("real-secret-token-value");
|
|
});
|
|
|
|
it("preserves explicitly changed sensitive values", () => {
|
|
const incoming = {
|
|
gateway: { auth: { token: "new-token-value-from-user" } },
|
|
};
|
|
const original = {
|
|
gateway: { auth: { token: "old-token-value" } },
|
|
};
|
|
const result = restoreRedactedValues(incoming, original) as typeof incoming;
|
|
expect(result.gateway.auth.token).toBe("new-token-value-from-user");
|
|
});
|
|
|
|
it("preserves non-sensitive fields unchanged", () => {
|
|
const incoming = {
|
|
ui: { seamColor: "#ff0000" },
|
|
gateway: { port: 9999, auth: { token: REDACTED_SENTINEL } },
|
|
};
|
|
const original = {
|
|
ui: { seamColor: "#0088cc" },
|
|
gateway: { port: 18789, auth: { token: "real-secret" } },
|
|
};
|
|
const result = restoreRedactedValues(incoming, original) as typeof incoming;
|
|
expect(result.ui.seamColor).toBe("#ff0000");
|
|
expect(result.gateway.port).toBe(9999);
|
|
expect(result.gateway.auth.token).toBe("real-secret");
|
|
});
|
|
|
|
it("handles deeply nested sentinel restoration", () => {
|
|
const incoming = {
|
|
channels: {
|
|
slack: {
|
|
accounts: {
|
|
ws1: { botToken: REDACTED_SENTINEL },
|
|
ws2: { botToken: "user-typed-new-token-value" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const original = {
|
|
channels: {
|
|
slack: {
|
|
accounts: {
|
|
ws1: { botToken: "original-ws1-token-value" },
|
|
ws2: { botToken: "original-ws2-token-value" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const result = restoreRedactedValues(incoming, original) as typeof incoming;
|
|
expect(result.channels.slack.accounts.ws1.botToken).toBe("original-ws1-token-value");
|
|
expect(result.channels.slack.accounts.ws2.botToken).toBe("user-typed-new-token-value");
|
|
});
|
|
|
|
it("handles missing original gracefully", () => {
|
|
const incoming = {
|
|
channels: { newChannel: { token: REDACTED_SENTINEL } },
|
|
};
|
|
const original = {};
|
|
expect(restoreRedactedValues_orig(incoming, original).ok).toBe(false);
|
|
});
|
|
|
|
it("rejects invalid restore inputs", () => {
|
|
const invalidInputs = [null, undefined, "token-value"] as const;
|
|
for (const input of invalidInputs) {
|
|
const result = restoreRedactedValues_orig(input, { token: "x" });
|
|
expect(result.ok).toBe(false);
|
|
}
|
|
expect(restoreRedactedValues_orig("token-value", { token: "x" })).toEqual({
|
|
ok: false,
|
|
error: "input not an object",
|
|
});
|
|
});
|
|
|
|
it("returns a human-readable error when sentinel cannot be restored", () => {
|
|
const incoming = {
|
|
channels: { newChannel: { token: REDACTED_SENTINEL } },
|
|
};
|
|
const result = restoreRedactedValues_orig(incoming, {});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.humanReadableMessage).toContain(REDACTED_SENTINEL);
|
|
expect(result.humanReadableMessage).toContain("channels.newChannel.token");
|
|
});
|
|
|
|
it("keeps unmatched wildcard array entries unchanged outside extension paths", () => {
|
|
const hints: ConfigUiHints = {
|
|
"custom.*": { sensitive: true },
|
|
};
|
|
const incoming = {
|
|
custom: { items: [REDACTED_SENTINEL] },
|
|
};
|
|
const original = {
|
|
custom: { items: ["original-secret-value"] },
|
|
};
|
|
const result = restoreRedactedValues(incoming, original, hints) as typeof incoming;
|
|
expect(result.custom.items[0]).toBe(REDACTED_SENTINEL);
|
|
});
|
|
|
|
it("round-trips config through redact → restore", () => {
|
|
const originalConfig = {
|
|
gateway: { auth: { token: "gateway-auth-secret-token-value" }, port: 18789 },
|
|
channels: {
|
|
slack: { botToken: "fake-slack-token-placeholder-value" },
|
|
telegram: {
|
|
botToken: "fake-telegram-token-placeholder-value",
|
|
webhookSecret: "fake-tg-secret-placeholder-value",
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
apiKey: "sk-proj-fake-openai-api-key-value",
|
|
baseUrl: "https://api.openai.com",
|
|
},
|
|
},
|
|
},
|
|
ui: { seamColor: "#0088cc" },
|
|
};
|
|
const snapshot = makeSnapshot(originalConfig);
|
|
|
|
// Redact (simulates config.get response)
|
|
const redacted = redactConfigSnapshot(snapshot);
|
|
|
|
// Restore (simulates config.set before write)
|
|
const restored = restoreRedactedValues(redacted.config, snapshot.config);
|
|
|
|
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 as typeof originalConfig).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);
|
|
const config = result.config as typeof snapshot.config;
|
|
expect(config.agents.defaults.memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
|
|
expect(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");
|
|
});
|
|
});
|