security: redact credentials from config.get gateway responses (#9858)
* security: add skill/plugin code safety scanner module * security: integrate skill scanner into security audit * security: add pre-install code safety scan for plugins * style: fix curly brace lint errors in skill-scanner.ts * docs: add changelog entry for skill code safety scanner * security: redact credentials from config.get gateway responses The config.get gateway method returned the full config snapshot including channel credentials (Discord tokens, Slack botToken/appToken, Telegram botToken, Feishu appSecret, etc.), model provider API keys, and gateway auth tokens in plaintext. Any WebSocket client—including the unauthenticated Control UI when dangerouslyDisableDeviceAuth is set—could read every secret. This adds redactConfigSnapshot() which: - Deep-walks the config object and masks any field whose key matches token, password, secret, or apiKey patterns - Uses the existing redactSensitiveText() to scrub the raw JSON5 source - Preserves the hash for change detection - Includes 15 test cases covering all channel types * security: make gateway config writes return redacted values * test: disable control UI by default in gateway server tests * fix: redact credentials in gateway config APIs (#9858) (thanks @abdelsfane) --------- Co-authored-by: George Pickett <gpickett00@gmail.com>main
parent
5f6e1c19bd
commit
0c7fa2b0d5
|
|
@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Web UI: apply button styling to the new-messages indicator.
|
||||
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
|
||||
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
|
||||
- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane.
|
||||
- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted.
|
||||
- Slack: strip `<@...>` mention tokens before command matching so `/new` and `/reset` work when prefixed with a mention. (#9971) Thanks @ironbyte-rgb.
|
||||
- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConfigFileSnapshot } from "./types.openclaw.js";
|
||||
import {
|
||||
REDACTED_SENTINEL,
|
||||
redactConfigSnapshot,
|
||||
restoreRedactedValues,
|
||||
} from "./redact-snapshot.js";
|
||||
|
||||
function makeSnapshot(config: Record<string, unknown>, raw?: string): ConfigFileSnapshot {
|
||||
return {
|
||||
path: "/home/user/.openclaw/config.json5",
|
||||
exists: true,
|
||||
raw: raw ?? JSON.stringify(config),
|
||||
parsed: config,
|
||||
valid: true,
|
||||
config: config as ConfigFileSnapshot["config"],
|
||||
hash: "abc123",
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("redactConfigSnapshot", () => {
|
||||
it("redacts top-level token fields", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
gateway: { auth: { token: "my-super-secret-gateway-token-value" } },
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
expect(result.config).toEqual({
|
||||
gateway: { auth: { token: REDACTED_SENTINEL } },
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts botToken in channel configs", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
channels: {
|
||||
telegram: { botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef" },
|
||||
slack: { botToken: "fake-slack-bot-token-placeholder-value" },
|
||||
},
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const channels = result.config.channels as Record<string, Record<string, string>>;
|
||||
expect(channels.telegram.botToken).toBe(REDACTED_SENTINEL);
|
||||
expect(channels.slack.botToken).toBe(REDACTED_SENTINEL);
|
||||
});
|
||||
|
||||
it("redacts apiKey in model providers", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
models: {
|
||||
providers: {
|
||||
openai: { apiKey: "sk-proj-abcdef1234567890ghij", baseUrl: "https://api.openai.com" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const models = result.config.models as Record<string, Record<string, Record<string, string>>>;
|
||||
expect(models.providers.openai.apiKey).toBe(REDACTED_SENTINEL);
|
||||
expect(models.providers.openai.baseUrl).toBe("https://api.openai.com");
|
||||
});
|
||||
|
||||
it("redacts password fields", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
gateway: { auth: { password: "super-secret-password-value-here" } },
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const gw = result.config.gateway as Record<string, Record<string, string>>;
|
||||
expect(gw.auth.password).toBe(REDACTED_SENTINEL);
|
||||
});
|
||||
|
||||
it("redacts appSecret fields", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
channels: {
|
||||
feishu: { appSecret: "feishu-app-secret-value-here-1234" },
|
||||
},
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const channels = result.config.channels as Record<string, Record<string, string>>;
|
||||
expect(channels.feishu.appSecret).toBe(REDACTED_SENTINEL);
|
||||
});
|
||||
|
||||
it("redacts signingSecret fields", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
channels: {
|
||||
slack: { signingSecret: "slack-signing-secret-value-1234" },
|
||||
},
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const channels = result.config.channels as Record<string, Record<string, string>>;
|
||||
expect(channels.slack.signingSecret).toBe(REDACTED_SENTINEL);
|
||||
});
|
||||
|
||||
it("redacts short secrets with same sentinel", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
gateway: { auth: { token: "short" } },
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const gw = result.config.gateway as Record<string, Record<string, string>>;
|
||||
expect(gw.auth.token).toBe(REDACTED_SENTINEL);
|
||||
});
|
||||
|
||||
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("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 object as well", () => {
|
||||
const config = {
|
||||
channels: { discord: { token: "MTIzNDU2Nzg5MDEyMzQ1Njc4.GaBcDe.FgH" } },
|
||||
};
|
||||
const snapshot = makeSnapshot(config);
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const parsed = result.parsed as Record<string, Record<string, Record<string, string>>>;
|
||||
expect(parsed.channels.discord.token).toBe(REDACTED_SENTINEL);
|
||||
});
|
||||
|
||||
it("handles null raw gracefully", () => {
|
||||
const snapshot: ConfigFileSnapshot = {
|
||||
path: "/test",
|
||||
exists: false,
|
||||
raw: null,
|
||||
parsed: null,
|
||||
valid: false,
|
||||
config: {} as ConfigFileSnapshot["config"],
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
expect(result.raw).toBeNull();
|
||||
expect(result.parsed).toBeNull();
|
||||
});
|
||||
|
||||
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("handles webhookSecret field", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
channels: {
|
||||
telegram: { webhookSecret: "telegram-webhook-secret-value-1234" },
|
||||
},
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const channels = result.config.channels as Record<string, Record<string, string>>;
|
||||
expect(channels.telegram.webhookSecret).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>>;
|
||||
expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL);
|
||||
// NODE_ENV is not sensitive, should be preserved
|
||||
expect(env.vars.NODE_ENV).toBe("production");
|
||||
});
|
||||
|
||||
it("redacts raw by key pattern even when parsed config is empty", () => {
|
||||
const snapshot: ConfigFileSnapshot = {
|
||||
path: "/test",
|
||||
exists: true,
|
||||
raw: '{ token: "raw-secret-1234567890" }',
|
||||
parsed: {},
|
||||
valid: false,
|
||||
config: {} as ConfigFileSnapshot["config"],
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
expect(result.raw).not.toContain("raw-secret-1234567890");
|
||||
expect(result.raw).toContain(REDACTED_SENTINEL);
|
||||
});
|
||||
|
||||
it("redacts sensitive fields even when the value is not a string", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
gateway: { auth: { token: 1234 } },
|
||||
});
|
||||
const result = redactConfigSnapshot(snapshot);
|
||||
const gw = result.config.gateway as Record<string, Record<string, string>>;
|
||||
expect(gw.auth.token).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(incoming, original)).toThrow(/redacted/i);
|
||||
});
|
||||
|
||||
it("handles null and undefined inputs", () => {
|
||||
expect(restoreRedactedValues(null, { token: "x" })).toBeNull();
|
||||
expect(restoreRedactedValues(undefined, { token: "x" })).toBeUndefined();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import type { ConfigFileSnapshot } from "./types.openclaw.js";
|
||||
|
||||
/**
|
||||
* Sentinel value used to replace sensitive config fields in gateway responses.
|
||||
* Write-side handlers (config.set, config.apply, config.patch) detect this
|
||||
* sentinel and restore the original value from the on-disk config, so a
|
||||
* round-trip through the Web UI does not corrupt credentials.
|
||||
*/
|
||||
export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
|
||||
|
||||
/**
|
||||
* Patterns that identify sensitive config field names.
|
||||
* Aligned with the UI-hint logic in schema.ts.
|
||||
*/
|
||||
const SENSITIVE_KEY_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
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;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function redactConfigObject<T>(value: T): T {
|
||||
return redactObject(value) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all sensitive string values from a config object.
|
||||
* Used for text-based redaction of the raw JSON5 source.
|
||||
*/
|
||||
function collectSensitiveValues(obj: unknown): string[] {
|
||||
const values: string[] = [];
|
||||
if (obj === null || obj === undefined || typeof obj !== "object") {
|
||||
return values;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
values.push(...collectSensitiveValues(item));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (isSensitiveKey(key) && typeof value === "string" && value.length > 0) {
|
||||
values.push(value);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
values.push(...collectSensitiveValues(value));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace known sensitive values in a raw JSON5 string with the sentinel.
|
||||
* Values are replaced longest-first to avoid partial matches.
|
||||
*/
|
||||
function redactRawText(raw: string, config: unknown): string {
|
||||
const sensitiveValues = collectSensitiveValues(config);
|
||||
sensitiveValues.sort((a, b) => b.length - a.length);
|
||||
let result = raw;
|
||||
for (const value of sensitiveValues) {
|
||||
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
result = result.replace(new RegExp(escaped, "g"), REDACTED_SENTINEL);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the config snapshot with all sensitive fields
|
||||
* replaced by {@link REDACTED_SENTINEL}. The `hash` is preserved
|
||||
* (it tracks config identity, not content).
|
||||
*
|
||||
* Both `config` (the parsed object) and `raw` (the JSON5 source) are scrubbed
|
||||
* so no credential can leak through either path.
|
||||
*/
|
||||
export function redactConfigSnapshot(snapshot: ConfigFileSnapshot): ConfigFileSnapshot {
|
||||
const redactedConfig = redactConfigObject(snapshot.config);
|
||||
const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config) : null;
|
||||
const redactedParsed = snapshot.parsed ? redactConfigObject(snapshot.parsed) : snapshot.parsed;
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
config: redactedConfig,
|
||||
raw: redactedRaw,
|
||||
parsed: redactedParsed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-walk `incoming` and replace any {@link REDACTED_SENTINEL} values
|
||||
* (on sensitive keys) with the corresponding value from `original`.
|
||||
*
|
||||
* This is called by config.set / config.apply / config.patch before writing,
|
||||
* so that credentials survive a Web UI round-trip unmodified.
|
||||
*/
|
||||
export function restoreRedactedValues(incoming: unknown, original: unknown): unknown {
|
||||
if (incoming === null || incoming === undefined) {
|
||||
return incoming;
|
||||
}
|
||||
if (typeof incoming !== "object") {
|
||||
return incoming;
|
||||
}
|
||||
if (Array.isArray(incoming)) {
|
||||
const origArr = Array.isArray(original) ? original : [];
|
||||
return incoming.map((item, i) => restoreRedactedValues(item, origArr[i]));
|
||||
}
|
||||
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>)) {
|
||||
if (isSensitiveKey(key) && value === REDACTED_SENTINEL) {
|
||||
if (!(key in orig)) {
|
||||
throw new Error(
|
||||
`config write rejected: "${key}" is redacted; set an explicit value instead of ${REDACTED_SENTINEL}`,
|
||||
);
|
||||
}
|
||||
result[key] = orig[key];
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
result[key] = restoreRedactedValues(value, orig[key]);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -12,6 +12,11 @@ import {
|
|||
} from "../../config/config.js";
|
||||
import { applyLegacyMigrations } from "../../config/legacy.js";
|
||||
import { applyMergePatch } from "../../config/merge-patch.js";
|
||||
import {
|
||||
redactConfigObject,
|
||||
redactConfigSnapshot,
|
||||
restoreRedactedValues,
|
||||
} from "../../config/redact-snapshot.js";
|
||||
import { buildConfigSchema } from "../../config/schema.js";
|
||||
import {
|
||||
formatDoctorNonInteractiveHint,
|
||||
|
|
@ -100,7 +105,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||
return;
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
respond(true, snapshot, undefined);
|
||||
respond(true, redactConfigSnapshot(snapshot), undefined);
|
||||
},
|
||||
"config.schema": ({ params, respond }) => {
|
||||
if (!validateConfigSchemaParams(params)) {
|
||||
|
|
@ -185,13 +190,27 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
let restored: typeof validated.config;
|
||||
try {
|
||||
restored = restoreRedactedValues(
|
||||
validated.config,
|
||||
snapshot.config,
|
||||
) as typeof validated.config;
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(restored);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
path: CONFIG_PATH,
|
||||
config: validated.config,
|
||||
config: redactConfigObject(restored),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
|
@ -250,8 +269,19 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||
return;
|
||||
}
|
||||
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
|
||||
const migrated = applyLegacyMigrations(merged);
|
||||
const resolved = migrated.next ?? merged;
|
||||
let restoredMerge: unknown;
|
||||
try {
|
||||
restoredMerge = restoreRedactedValues(merged, snapshot.config);
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const migrated = applyLegacyMigrations(restoredMerge);
|
||||
const resolved = migrated.next ?? restoredMerge;
|
||||
const validated = validateConfigObjectWithPlugins(resolved);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
|
|
@ -306,7 +336,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||
{
|
||||
ok: true,
|
||||
path: CONFIG_PATH,
|
||||
config: validated.config,
|
||||
config: redactConfigObject(validated.config),
|
||||
restart,
|
||||
sentinel: {
|
||||
path: sentinelPath,
|
||||
|
|
@ -360,7 +390,21 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
let restoredApply: typeof validated.config;
|
||||
try {
|
||||
restoredApply = restoreRedactedValues(
|
||||
validated.config,
|
||||
snapshot.config,
|
||||
) as typeof validated.config;
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(restoredApply);
|
||||
|
||||
const sessionKey =
|
||||
typeof (params as { sessionKey?: unknown }).sessionKey === "string"
|
||||
|
|
@ -403,7 +447,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||
{
|
||||
ok: true,
|
||||
path: CONFIG_PATH,
|
||||
config: validated.config,
|
||||
config: redactConfigObject(restoredApply),
|
||||
restart,
|
||||
sentinel: {
|
||||
path: sentinelPath,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { resolveConfigSnapshotHash } from "../config/config.js";
|
||||
import { CONFIG_PATH, resolveConfigSnapshotHash } from "../config/config.js";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
|
|
@ -115,7 +115,82 @@ describe("gateway config.patch", () => {
|
|||
}>(ws, (o) => o.type === "res" && o.id === get2Id);
|
||||
expect(get2Res.ok).toBe(true);
|
||||
expect(get2Res.payload?.config?.gateway?.mode).toBe("local");
|
||||
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1");
|
||||
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("__OPENCLAW_REDACTED__");
|
||||
|
||||
const storedRaw = await fs.readFile(CONFIG_PATH, "utf-8");
|
||||
const stored = JSON.parse(storedRaw) as {
|
||||
channels?: { telegram?: { botToken?: string } };
|
||||
};
|
||||
expect(stored.channels?.telegram?.botToken).toBe("token-1");
|
||||
});
|
||||
|
||||
it("preserves credentials on config.set when raw contains redacted sentinels", async () => {
|
||||
const setId = "req-set-sentinel-1";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: setId,
|
||||
method: "config.set",
|
||||
params: {
|
||||
raw: JSON.stringify({
|
||||
gateway: { mode: "local" },
|
||||
channels: { telegram: { botToken: "token-1" } },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const setRes = await onceMessage<{ ok: boolean }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === setId,
|
||||
);
|
||||
expect(setRes.ok).toBe(true);
|
||||
|
||||
const getId = "req-get-sentinel-1";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: getId,
|
||||
method: "config.get",
|
||||
params: {},
|
||||
}),
|
||||
);
|
||||
const getRes = await onceMessage<{ ok: boolean; payload?: { hash?: string; raw?: string } }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === getId,
|
||||
);
|
||||
expect(getRes.ok).toBe(true);
|
||||
const baseHash = resolveConfigSnapshotHash({
|
||||
hash: getRes.payload?.hash,
|
||||
raw: getRes.payload?.raw,
|
||||
});
|
||||
expect(typeof baseHash).toBe("string");
|
||||
const rawRedacted = getRes.payload?.raw;
|
||||
expect(typeof rawRedacted).toBe("string");
|
||||
expect(rawRedacted).toContain("__OPENCLAW_REDACTED__");
|
||||
|
||||
const set2Id = "req-set-sentinel-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: set2Id,
|
||||
method: "config.set",
|
||||
params: {
|
||||
raw: rawRedacted,
|
||||
baseHash,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const set2Res = await onceMessage<{ ok: boolean }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === set2Id,
|
||||
);
|
||||
expect(set2Res.ok).toBe(true);
|
||||
|
||||
const storedRaw = await fs.readFile(CONFIG_PATH, "utf-8");
|
||||
const stored = JSON.parse(storedRaw) as {
|
||||
channels?: { telegram?: { botToken?: string } };
|
||||
};
|
||||
expect(stored.channels?.telegram?.botToken).toBe("token-1");
|
||||
});
|
||||
|
||||
it("writes config, stores sentinel, and schedules restart", async () => {
|
||||
|
|
|
|||
|
|
@ -590,6 +590,15 @@ vi.mock("../cli/deps.js", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/loader.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../plugins/loader.js")>("../plugins/loader.js");
|
||||
return {
|
||||
...actual,
|
||||
loadOpenClawPlugins: () => pluginRegistryState.registry,
|
||||
};
|
||||
});
|
||||
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
|
|
|
|||
|
|
@ -285,7 +285,9 @@ export function onceMessage<T = unknown>(
|
|||
|
||||
export async function startGatewayServer(port: number, opts?: GatewayServerOptions) {
|
||||
const mod = await serverModulePromise;
|
||||
return await mod.startGatewayServer(port, opts);
|
||||
const resolvedOpts =
|
||||
opts?.controlUiEnabled === undefined ? { ...opts, controlUiEnabled: false } : opts;
|
||||
return await mod.startGatewayServer(port, resolvedOpts);
|
||||
}
|
||||
|
||||
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
|
||||
|
|
@ -323,7 +325,30 @@ export async function startServerWithClient(token?: string, opts?: GatewayServer
|
|||
}
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
ws.off("open", onOpen);
|
||||
ws.off("error", onError);
|
||||
ws.off("close", onClose);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
cleanup();
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
const onClose = (code: number, reason: Buffer) => {
|
||||
cleanup();
|
||||
reject(new Error(`closed ${code}: ${reason.toString()}`));
|
||||
};
|
||||
ws.once("open", onOpen);
|
||||
ws.once("error", onError);
|
||||
ws.once("close", onClose);
|
||||
});
|
||||
return { server, ws, port, prevToken: prev };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue