Files
openclaw/src/config/redact-snapshot.restore.test.ts
Josh Avant 81b777c768 fix(config): harden SecretRef round-trip handling in Control UI and RPC writes (#58044)
* Config: harden SecretRef round-trip handling

* Gateway: test SecretRef preflight on config writes

* Agents: align skill loader with upstream Skill type

* Docs: align SecretRef write semantics with Control UI and RPC behavior

* Config: add UI and gateway regression evidence for SecretRef hardening

* Config: add token SecretRef restore regression and skill sourceInfo compat

* UI: scope structured-value lockout to SecretRef fields

* Agents: remove out-of-scope skill loader compat edits

* UI: reduce app-render churn to rawAvailable-only changes

* Gateway: scope SecretRef preflight to submitted config

* Docs: clarify config write SecretRef preflight scope

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-30 23:55:03 -05:00

327 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
REDACTED_SENTINEL,
redactConfigSnapshot,
restoreRedactedValues as restoreRedactedValues_orig,
} from "./redact-snapshot.js";
import { makeSnapshot, restoreRedactedValues } from "./redact-snapshot.test-helpers.js";
import { redactSnapshotTestHints as mainSchemaHints } from "./redact-snapshot.test-hints.js";
import type { ConfigUiHints } from "./schema.js";
describe("restoreRedactedValues", () => {
it("restores redacted URL endpoint fields on round-trip", () => {
const incoming = {
models: {
providers: {
openai: { baseUrl: REDACTED_SENTINEL },
},
},
};
const original = {
models: {
providers: {
openai: { baseUrl: "https://alice:secret@example.test/v1" },
},
},
};
const result = restoreRedactedValues(incoming, original, mainSchemaHints);
expect(result.models.providers.openai.baseUrl).toBe("https://alice:secret@example.test/v1");
});
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("rejects sentinel literals that survive restore", () => {
const hints: ConfigUiHints = {
"custom.*": { sensitive: true },
};
const incoming = {
custom: { items: [REDACTED_SENTINEL] },
};
const original = {
custom: { items: ["original-secret-value"] },
};
const result = restoreRedactedValues_orig(incoming, original, hints);
expect(result.ok).toBe(false);
expect(result.humanReadableMessage).toContain("Reserved redaction 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);
const redacted = redactConfigSnapshot(snapshot);
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("rejects sentinel literals even when uiHints mark the path non-sensitive", () => {
const hints: ConfigUiHints = {
"gateway.auth.token": { sensitive: false },
};
const incoming = {
gateway: { auth: { token: REDACTED_SENTINEL } },
};
const original = {
gateway: { auth: { token: "real-secret" } },
};
const result = restoreRedactedValues_orig(incoming, original, hints);
expect(result.ok).toBe(false);
expect(result.humanReadableMessage).toContain("Reserved redaction 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");
});
it("restores redacted SecretRef ids for channels token paths", () => {
const hints: ConfigUiHints = {
"channels.discord.token": { sensitive: true },
};
const incoming = {
channels: {
discord: {
token: {
source: "env",
provider: "default",
id: REDACTED_SENTINEL,
},
},
},
};
const original = {
channels: {
discord: {
token: {
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
},
},
},
};
const result = restoreRedactedValues(incoming, original, hints);
expect(result.channels.discord.token).toEqual({
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
});
});
it("rejects SecretRef source/provider changes when id is still redacted", () => {
const incoming = {
models: {
providers: {
default: {
apiKey: {
source: "file",
provider: "vault",
id: REDACTED_SENTINEL,
},
},
},
},
};
const original = {
models: {
providers: {
default: {
apiKey: {
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
},
},
},
},
};
const result = restoreRedactedValues_orig(incoming, original, mainSchemaHints);
expect(result.ok).toBe(false);
expect(result.humanReadableMessage).toContain("changed source/provider");
});
it("reports a provider-focused error when original SecretRefs lack provider", () => {
const incoming = {
models: {
providers: {
default: {
apiKey: {
source: "env",
id: REDACTED_SENTINEL,
},
},
},
},
};
const original = {
models: {
providers: {
default: {
apiKey: {
source: "env",
id: "OPENAI_API_KEY",
},
},
},
},
};
const result = restoreRedactedValues_orig(incoming, original, mainSchemaHints);
expect(result.ok).toBe(false);
expect(result.humanReadableMessage).toContain("requires a provider field");
});
});