fix(gateway): preserve existing skill secrets on redacted round-trips

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mason Huang
2026-04-27 16:20:17 +08:00
parent 8dae600677
commit 61fc06f33f
2 changed files with 73 additions and 9 deletions

View File

@@ -14,7 +14,7 @@ import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
import { listAgentWorkspaceDirs } from "../../agents/workspace-dirs.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { redactConfigObject } from "../../config/redact-snapshot.js";
import { redactConfigObject, REDACTED_SENTINEL } from "../../config/redact-snapshot.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { fetchClawHubSkillDetail } from "../../infra/clawhub.js";
import { formatErrorMessage } from "../../infra/errors.js";
@@ -313,7 +313,9 @@ export const skillsHandlers: GatewayRequestHandlers = {
}
if (typeof p.apiKey === "string") {
const trimmed = normalizeSecretInput(p.apiKey);
if (trimmed) {
if (trimmed === REDACTED_SENTINEL) {
// Keep the stored secret when a client round-trips a redacted response value.
} else if (trimmed) {
current.apiKey = trimmed;
} else {
delete current.apiKey;
@@ -327,6 +329,9 @@ export const skillsHandlers: GatewayRequestHandlers = {
continue;
}
const trimmedVal = value.trim();
if (trimmedVal === REDACTED_SENTINEL) {
continue;
}
if (!trimmedVal) {
delete nextEnv[trimmedKey];
} else {

View File

@@ -1,14 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { REDACTED_SENTINEL } from "../../config/redact-snapshot.js";
let writtenConfig: unknown = null;
let loadedConfig: unknown = {
skills: {
entries: {},
},
};
vi.mock("../../config/config.js", () => {
return {
loadConfig: () => ({
skills: {
entries: {},
},
}),
loadConfig: () => loadedConfig,
writeConfigFile: async (cfg: unknown) => {
writtenConfig = cfg;
},
@@ -20,6 +22,11 @@ const { skillsHandlers } = await import("./skills.js");
describe("skills.update", () => {
it("strips embedded CR/LF from apiKey", async () => {
writtenConfig = null;
loadedConfig = {
skills: {
entries: {},
},
};
let ok: boolean | null = null;
let error: unknown = null;
@@ -53,6 +60,11 @@ describe("skills.update", () => {
it("redacts apiKey and secret env values from the response but writes full values to config", async () => {
writtenConfig = null;
loadedConfig = {
skills: {
entries: {},
},
};
let responseResult: unknown = null;
await skillsHandlers["skills.update"]({
@@ -90,10 +102,57 @@ describe("skills.update", () => {
// Response must not expose plaintext secrets
const config = (responseResult as { config: Record<string, unknown> }).config;
expect(config.apiKey).toBe("__OPENCLAW_REDACTED__");
expect(config.apiKey).toBe(REDACTED_SENTINEL);
const env = config.env as Record<string, string>;
expect(env.GEMINI_API_KEY).toBe("__OPENCLAW_REDACTED__");
expect(env.GEMINI_API_KEY).toBe(REDACTED_SENTINEL);
// Non-secret env values should still be present
expect(env.BRAVE_REGION).toBe("us");
});
it("keeps existing secrets when clients submit redacted sentinel values", async () => {
writtenConfig = null;
loadedConfig = {
skills: {
entries: {
"demo-skill": {
apiKey: "secret-api-key-123",
env: {
GEMINI_API_KEY: "secret-env-key-456",
BRAVE_REGION: "us",
},
},
},
},
};
await skillsHandlers["skills.update"]({
params: {
skillKey: "demo-skill",
apiKey: REDACTED_SENTINEL,
env: {
GEMINI_API_KEY: REDACTED_SENTINEL,
BRAVE_REGION: "eu",
},
},
req: {} as never,
client: null as never,
isWebchatConnect: () => false,
context: {} as never,
respond: () => {},
});
expect(writtenConfig).toMatchObject({
skills: {
entries: {
"demo-skill": {
apiKey: "secret-api-key-123",
env: {
GEMINI_API_KEY: "secret-env-key-456",
BRAVE_REGION: "eu",
},
},
},
},
});
});
});