diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index f3422bd6ce8..0f5af972960 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -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 { diff --git a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts index 59a7073c185..1dc80a801da 100644 --- a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts +++ b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts @@ -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 }).config; - expect(config.apiKey).toBe("__OPENCLAW_REDACTED__"); + expect(config.apiKey).toBe(REDACTED_SENTINEL); const env = config.env as Record; - 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", + }, + }, + }, + }, + }); + }); });