diff --git a/__tests__/utils/redact-custom-secrets.test.ts b/__tests__/utils/redact-custom-secrets.test.ts new file mode 100644 index 000000000..1ede6a20e --- /dev/null +++ b/__tests__/utils/redact-custom-secrets.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { redactCustomSecrets } from "#/utils/redact-custom-secrets"; + +describe("redactCustomSecrets", () => { + it("preserves already-masked secret values", () => { + const text = + "\nMY_API_KEY=\n"; + expect(redactCustomSecrets(text)).toBe(text); + }); + + it("redacts an unmasked value inside the block (= separator)", () => { + const text = "\nMY_API_KEY=leaked-value\n"; + const result = redactCustomSecrets(text); + expect(result).not.toContain("leaked-value"); + expect(result).toContain("MY_API_KEY="); + }); + + it("redacts an unmasked value inside the block (: separator)", () => { + const text = + "\nMY_API_KEY: leaked-value\n"; + const result = redactCustomSecrets(text); + expect(result).not.toContain("leaked-value"); + expect(result).toContain("MY_API_KEY: "); + }); + + it("redacts only inside the block, leaving surrounding text untouched", () => { + const text = + "2026-06-01\n" + + "\nTOKEN=abc123\n\n" + + "key=value-outside-block"; + const result = redactCustomSecrets(text); + expect(result).toContain("2026-06-01"); + expect(result).toContain("key=value-outside-block"); + expect(result).not.toContain("abc123"); + expect(result).toContain("TOKEN="); + }); + + it("returns text unchanged when there is no CUSTOM_SECRETS block", () => { + const text = "my-skill\nkey=value"; + expect(redactCustomSecrets(text)).toBe(text); + }); + + it("redacts multiple secrets within the block", () => { + const text = + "\nA=one\nB=\nC=three\n"; + const result = redactCustomSecrets(text); + expect(result).not.toContain("one"); + expect(result).not.toContain("three"); + expect(result).toContain("A="); + expect(result).toContain("B="); + expect(result).toContain("C="); + }); + + it("redacts even when the closing tag is missing (truncated block)", () => { + const text = "\nMY_API_KEY=leaked-value"; + const result = redactCustomSecrets(text); + expect(result).not.toContain("leaked-value"); + expect(result).toContain("MY_API_KEY="); + }); + + it("stays idempotent across repeated calls", () => { + const text = "\nTOKEN=leaked-value\n"; + const once = redactCustomSecrets(text); + const twice = redactCustomSecrets(once); + expect(twice).toBe(once); + expect(twice).not.toContain("leaked-value"); + }); +}); diff --git a/__tests__/utils/system-message-adapter.test.ts b/__tests__/utils/system-message-adapter.test.ts index 32bd2f9fd..d0212e85e 100644 --- a/__tests__/utils/system-message-adapter.test.ts +++ b/__tests__/utils/system-message-adapter.test.ts @@ -34,4 +34,44 @@ describe("adaptSystemMessage", () => { it("should return null when no system message is present in events", () => { expect(adaptSystemMessage([])).toBeNull(); }); + + it("should leave content unchanged when dynamic_context is absent", () => { + const result = adaptSystemMessage(v1Event); + expect(result?.content).toBe("v1 prompt"); + }); + + it("should append dynamic_context to the system prompt content", () => { + const events: EventState["events"] = [ + { + id: "v1-id", + timestamp: "2025-12-30T12:00:00Z", + source: "agent", + system_prompt: { type: "text", text: "v1 prompt" }, + tools: [], + dynamic_context: { type: "text", text: "my-skill" }, + }, + ]; + const result = adaptSystemMessage(events); + expect(result?.content).toContain("v1 prompt"); + expect(result?.content).toContain("my-skill"); + }); + + it("should redact unmasked custom secret values in dynamic_context", () => { + const events: EventState["events"] = [ + { + id: "v1-id", + timestamp: "2025-12-30T12:00:00Z", + source: "agent", + system_prompt: { type: "text", text: "v1 prompt" }, + tools: [], + dynamic_context: { + type: "text", + text: "\nMY_API_KEY=super-secret-value\n", + }, + }, + ]; + const result = adaptSystemMessage(events); + expect(result?.content).not.toContain("super-secret-value"); + expect(result?.content).toContain("MY_API_KEY="); + }); }); diff --git a/src/components/features/conversation-panel/system-message-modal/tab-content.tsx b/src/components/features/conversation-panel/system-message-modal/tab-content.tsx index fba2bb849..9b32eb9ce 100644 --- a/src/components/features/conversation-panel/system-message-modal/tab-content.tsx +++ b/src/components/features/conversation-panel/system-message-modal/tab-content.tsx @@ -1,14 +1,11 @@ import { SystemMessageContent } from "./system-message-content"; import { ToolsList } from "./tools-list"; import { EmptyToolsState } from "./empty-tools-state"; -import { ChatCompletionToolParam } from "#/types/agent-server/core"; +import { SystemMessageForModal } from "#/utils/system-message-adapter"; interface TabContentProps { activeTab: "system" | "tools"; - systemMessage: { - content: string; - tools: Array> | ChatCompletionToolParam[] | null; - }; + systemMessage: SystemMessageForModal; expandedTools: Record; onToggleTool: (index: number) => void; } diff --git a/src/types/agent-server/core/events/system-event.ts b/src/types/agent-server/core/events/system-event.ts index a4a795328..c2d49f822 100644 --- a/src/types/agent-server/core/events/system-event.ts +++ b/src/types/agent-server/core/events/system-event.ts @@ -17,4 +17,10 @@ export interface SystemPromptEvent extends BaseEvent { * List of tools in OpenAI tool format */ tools: ChatCompletionToolParam[]; + + /** + * Runtime-injected context appended to the system message (datetime, skills, + * runtime services, secrets). Optional for older persisted events. + */ + dynamic_context?: TextContent; } diff --git a/src/utils/redact-custom-secrets.ts b/src/utils/redact-custom-secrets.ts new file mode 100644 index 000000000..1984c4893 --- /dev/null +++ b/src/utils/redact-custom-secrets.ts @@ -0,0 +1,29 @@ +const MASKED_PLACEHOLDER = ""; + +// Closing tag is optional so a truncated block is still redacted, not leaked. +const CUSTOM_SECRETS_BLOCK = + /()([\s\S]*?)(<\/CUSTOM_SECRETS>|$)/gi; + +// `KEY: value` / `KEY=value`, capturing key + separator so only the value changes. +const SECRET_LINE = /^(\s*[^=:\n]+?\s*[:=]\s*)(.+?)\s*$/gm; + +/** + * Defensive backstop: redact any unmasked value inside a `` + * block before showing dynamic context in the UI, in case backend masking + * regresses. Text outside the block is untouched. + */ +export function redactCustomSecrets(text: string): string { + return text.replace( + CUSTOM_SECRETS_BLOCK, + (_match, open: string, body: string, close: string) => { + const redactedBody = body.replace( + SECRET_LINE, + (lineMatch, prefix: string, value: string) => + value === MASKED_PLACEHOLDER + ? lineMatch + : `${prefix}${MASKED_PLACEHOLDER}`, + ); + return `${open}${redactedBody}${close}`; + }, + ); +} diff --git a/src/utils/system-message-adapter.ts b/src/utils/system-message-adapter.ts index d7f912524..487594e4c 100644 --- a/src/utils/system-message-adapter.ts +++ b/src/utils/system-message-adapter.ts @@ -1,6 +1,7 @@ import { OHEvent } from "#/stores/use-event-store"; import { ChatCompletionToolParam } from "#/types/agent-server/core"; import { isSystemPromptEvent } from "#/types/agent-server/type-guards"; +import { redactCustomSecrets } from "#/utils/redact-custom-secrets"; export interface SystemMessageForModal { content: string; @@ -18,8 +19,15 @@ export function adaptSystemMessage( return null; } + // dynamic_context is the runtime-injected tail of the same system prompt the + // model receives, so append it to show the full message as one block. + const dynamicContextText = systemPromptEvent.dynamic_context?.text; + const content = dynamicContextText + ? `${systemPromptEvent.system_prompt.text}\n\n${redactCustomSecrets(dynamicContextText)}` + : systemPromptEvent.system_prompt.text; + return { - content: systemPromptEvent.system_prompt.text, + content, tools: systemPromptEvent.tools ?? null, openhands_version: null, agent_class: null,