import { describe, expect, it } from "vitest"; import { z } from "zod"; import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js"; import { isSensitiveUrlConfigPath } from "../shared/net/redact-sensitive-url.js"; import { FIELD_HELP } from "./schema.help.js"; import { __test__, isPluginOwnedChannelHintPath, isSensitiveConfigPath } from "./schema.hints.js"; import { FIELD_LABELS } from "./schema.labels.js"; import { OpenClawSchema } from "./zod-schema.js"; import { sensitive } from "./zod-schema.sensitive.js"; const { collectMatchingSchemaPaths, mapSensitivePaths } = __test__; const BUNDLED_CHANNEL_HINT_PREFIXES = [ "channels.bluebubbles", "channels.discord", "channels.imessage", "channels.irc", "channels.msteams", "channels.signal", "channels.slack", "channels.telegram", "channels.whatsapp", ] as const; describe("isSensitiveConfigPath", () => { it("matches whitelist suffixes case-insensitively", () => { const whitelistedPaths = [ "maxTokens", "maxOutputTokens", "maxInputTokens", "maxCompletionTokens", "contextTokens", "totalTokens", "tokenCount", "tokenLimit", "tokenBudget", "channels.irc.nickserv.passwordFile", ]; for (const path of whitelistedPaths) { expect(isSensitiveConfigPath(path)).toBe(false); expect(isSensitiveConfigPath(path.toUpperCase())).toBe(false); } }); it("keeps true sensitive keys redacted", () => { expect(isSensitiveConfigPath("channels.slack.token")).toBe(true); expect(isSensitiveConfigPath("models.providers.openai.apiKey")).toBe(true); expect(isSensitiveConfigPath("channels.irc.nickserv.password")).toBe(true); expect(isSensitiveConfigPath("channels.feishu.encryptKey")).toBe(true); expect(isSensitiveConfigPath("channels.feishu.accounts.default.encryptKey")).toBe(true); expect(isSensitiveConfigPath("channels.nostr.privateKey")).toBe(true); expect(isSensitiveConfigPath("channels.nostr.accounts.default.privateKey")).toBe(true); }); }); describe("plugin-owned channel hint paths", () => { it("keeps bundled channel help and labels out of core tables", () => { for (const key of [...Object.keys(FIELD_HELP), ...Object.keys(FIELD_LABELS)]) { if ( !BUNDLED_CHANNEL_HINT_PREFIXES.some( (prefix) => key === prefix || key.startsWith(`${prefix}.`), ) ) { continue; } expect(isPluginOwnedChannelHintPath(key), `core still owns ${key}`).toBe(false); } }); }); 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("maps sensitive fields nested under object catchall schemas", () => { const schema = z.object({ custom: z.object({}).catchall( z.object({ apiKey: z.string().register(sensitive), label: z.string(), }), ), }); const result = mapSensitivePaths(schema, "", {}); expect(result["custom.*.apiKey"]?.sensitive).toBe(true); expect(result["custom.*.label"]?.sensitive).toBe(undefined); }); it("does not mark plain catchall values sensitive by default", () => { const schema = z.object({ env: z.object({}).catchall(z.string()), }); const result = mapSensitivePaths(schema, "", {}); expect(result["env.*"]?.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["gateway.auth.token"]?.sensitive).toBe(true); expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true); expect(hints["models.providers.*.request.headers.*"]?.sensitive).toBe(true); expect(hints["models.providers.*.request.proxy.tls.cert"]?.sensitive).toBe(true); expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true); }); it("marks buildSecretInputSchema fields as sensitive via registry", () => { const schema = z.object({ encryptKey: buildSecretInputSchema().optional(), appSecret: buildSecretInputSchema().optional(), nested: z.object({ verificationToken: buildSecretInputSchema().optional(), }), }); const hints = mapSensitivePaths(schema, "", {}); expect(hints["encryptKey"]?.sensitive).toBe(true); expect(hints["appSecret"]?.sensitive).toBe(true); expect(hints["nested.verificationToken"]?.sensitive).toBe(true); }); }); describe("collectMatchingSchemaPaths", () => { it("finds base-config URL fields that may embed secrets", () => { const paths = collectMatchingSchemaPaths(OpenClawSchema, "", isSensitiveUrlConfigPath); expect(paths.has("mcp.servers.*.url")).toBe(true); expect(paths.has("models.providers.*.baseUrl")).toBe(true); expect(paths.has("models.providers.*.request.proxy.url")).toBe(true); expect(paths.has("tools.media.audio.request.proxy.url")).toBe(true); }); });