From 493dba963ef928f18eb16ff9910937b6252f5dd4 Mon Sep 17 00:00:00 2001 From: "burak.keles" Date: Mon, 1 Jun 2026 13:50:08 +0300 Subject: [PATCH 1/2] fix: surface dynamic_context in Agent Tools & Metadata modal (#932) The backend SystemPromptEvent carries three content fields (system_prompt, tools, dynamic_context), but the frontend only read the first two. dynamic_context (datetime, skills catalog, runtime services, custom secrets) is part of the system message the model actually receives and is larger than the static prompt, so the modal showed less than half of the agent's true context. - Add optional dynamic_context to the SystemPromptEvent interface - Surface it via adaptSystemMessage and a new "Dynamic Context" tab - Defensively redact unmasked values client-side - Reset to the System tab on open so a now-hidden tab can't leave an empty panel - Add tests and translations for all locales Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/utils/redact-custom-secrets.test.ts | 68 +++++++++++++++++++ .../utils/system-message-adapter.test.ts | 39 +++++++++++ .../system-message-modal.tsx | 18 ++++- .../system-message-modal/tab-content.tsx | 16 +++-- .../system-message-modal/tab-navigation.tsx | 16 ++++- src/i18n/translation.json | 17 +++++ .../agent-server/core/events/system-event.ts | 6 ++ src/utils/redact-custom-secrets.ts | 29 ++++++++ src/utils/system-message-adapter.ts | 7 ++ 9 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 __tests__/utils/redact-custom-secrets.test.ts create mode 100644 src/utils/redact-custom-secrets.ts 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..8b5abb7f5 100644 --- a/__tests__/utils/system-message-adapter.test.ts +++ b/__tests__/utils/system-message-adapter.test.ts @@ -34,4 +34,43 @@ describe("adaptSystemMessage", () => { it("should return null when no system message is present in events", () => { expect(adaptSystemMessage([])).toBeNull(); }); + + it("should leave dynamicContext null when dynamic_context is absent", () => { + const result = adaptSystemMessage(v1Event); + expect(result?.dynamicContext).toBeNull(); + }); + + it("should surface dynamic_context text when present", () => { + 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?.dynamicContext).toBe("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?.dynamicContext).not.toContain("super-secret-value"); + expect(result?.dynamicContext).toContain("MY_API_KEY="); + }); }); diff --git a/src/components/features/conversation-panel/system-message-modal.tsx b/src/components/features/conversation-panel/system-message-modal.tsx index c4a87b7e0..f3f0edfbb 100644 --- a/src/components/features/conversation-panel/system-message-modal.tsx +++ b/src/components/features/conversation-panel/system-message-modal.tsx @@ -1,8 +1,11 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; import { SystemMessageHeader } from "./system-message-modal/system-message-header"; -import { TabNavigation } from "./system-message-modal/tab-navigation"; +import { + TabNavigation, + SystemMessageTab, +} from "./system-message-modal/tab-navigation"; import { TabContent } from "./system-message-modal/tab-content"; import { SystemMessageForModal } from "#/utils/system-message-adapter"; @@ -17,11 +20,19 @@ export function SystemMessageModal({ onClose, systemMessage, }: SystemMessageModalProps) { - const [activeTab, setActiveTab] = useState<"system" | "tools">("system"); + const [activeTab, setActiveTab] = useState("system"); const [expandedTools, setExpandedTools] = useState>( {}, ); + // Reset to System on open so a previously selected, now-hidden tab can't + // leave the modal showing an empty panel. + useEffect(() => { + if (isOpen) { + setActiveTab("system"); + } + }, [isOpen]); + if (!systemMessage) { return null; } @@ -51,6 +62,7 @@ export function SystemMessageModal({ 0) } 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..c148c75f4 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,12 @@ 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 { SystemMessageTab } from "./tab-navigation"; +import { SystemMessageForModal } from "#/utils/system-message-adapter"; interface TabContentProps { - activeTab: "system" | "tools"; - systemMessage: { - content: string; - tools: Array> | ChatCompletionToolParam[] | null; - }; + activeTab: SystemMessageTab; + systemMessage: SystemMessageForModal; expandedTools: Record; onToggleTool: (index: number) => void; } @@ -23,6 +21,12 @@ export function TabContent({ return ; } + if (activeTab === "dynamic") { + return ( + + ); + } + if (activeTab === "tools") { if (systemMessage.tools && systemMessage.tools.length > 0) { return ( diff --git a/src/components/features/conversation-panel/system-message-modal/tab-navigation.tsx b/src/components/features/conversation-panel/system-message-modal/tab-navigation.tsx index c8522b138..f913d6be6 100644 --- a/src/components/features/conversation-panel/system-message-modal/tab-navigation.tsx +++ b/src/components/features/conversation-panel/system-message-modal/tab-navigation.tsx @@ -1,15 +1,19 @@ import { useTranslation } from "react-i18next"; import { TabButton } from "./tab-button"; +export type SystemMessageTab = "system" | "dynamic" | "tools"; + interface TabNavigationProps { - activeTab: "system" | "tools"; - onTabChange: (tab: "system" | "tools") => void; + activeTab: SystemMessageTab; + onTabChange: (tab: SystemMessageTab) => void; + hasDynamicContext: boolean; hasTools: boolean; } export function TabNavigation({ activeTab, onTabChange, + hasDynamicContext, hasTools, }: TabNavigationProps) { const { t } = useTranslation("openhands"); @@ -25,6 +29,14 @@ export function TabNavigation({ > {t("SYSTEM_MESSAGE_MODAL$SYSTEM_MESSAGE_TAB")} + {hasDynamicContext && ( + onTabChange("dynamic")} + > + {t("SYSTEM_MESSAGE_MODAL$DYNAMIC_CONTEXT_TAB")} + + )} {hasTools && ( )([\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..af5b880cb 100644 --- a/src/utils/system-message-adapter.ts +++ b/src/utils/system-message-adapter.ts @@ -1,9 +1,11 @@ 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; + dynamicContext: string | null; tools: ChatCompletionToolParam[] | Record[] | null; openhands_version: string | null; agent_class: string | null; @@ -18,8 +20,13 @@ export function adaptSystemMessage( return null; } + const dynamicContextText = systemPromptEvent.dynamic_context?.text; + return { content: systemPromptEvent.system_prompt.text, + dynamicContext: dynamicContextText + ? redactCustomSecrets(dynamicContextText) + : null, tools: systemPromptEvent.tools ?? null, openhands_version: null, agent_class: null, From 529e63f8fc99476e33437e1b1451961b8f8d67fa Mon Sep 17 00:00:00 2001 From: "burak.keles" Date: Tue, 2 Jun 2026 18:14:49 +0300 Subject: [PATCH 2/2] refactor: remove "Dynamic Context" tab and consolidate context in "System" tab --- __tests__/utils/system-message-adapter.test.ts | 13 +++++++------ .../system-message-modal.tsx | 18 +++--------------- .../system-message-modal/tab-content.tsx | 9 +-------- .../system-message-modal/tab-navigation.tsx | 16 ++-------------- src/i18n/translation.json | 17 ----------------- src/utils/system-message-adapter.ts | 11 ++++++----- 6 files changed, 19 insertions(+), 65 deletions(-) diff --git a/__tests__/utils/system-message-adapter.test.ts b/__tests__/utils/system-message-adapter.test.ts index 8b5abb7f5..d0212e85e 100644 --- a/__tests__/utils/system-message-adapter.test.ts +++ b/__tests__/utils/system-message-adapter.test.ts @@ -35,12 +35,12 @@ describe("adaptSystemMessage", () => { expect(adaptSystemMessage([])).toBeNull(); }); - it("should leave dynamicContext null when dynamic_context is absent", () => { + it("should leave content unchanged when dynamic_context is absent", () => { const result = adaptSystemMessage(v1Event); - expect(result?.dynamicContext).toBeNull(); + expect(result?.content).toBe("v1 prompt"); }); - it("should surface dynamic_context text when present", () => { + it("should append dynamic_context to the system prompt content", () => { const events: EventState["events"] = [ { id: "v1-id", @@ -52,7 +52,8 @@ describe("adaptSystemMessage", () => { }, ]; const result = adaptSystemMessage(events); - expect(result?.dynamicContext).toBe("my-skill"); + expect(result?.content).toContain("v1 prompt"); + expect(result?.content).toContain("my-skill"); }); it("should redact unmasked custom secret values in dynamic_context", () => { @@ -70,7 +71,7 @@ describe("adaptSystemMessage", () => { }, ]; const result = adaptSystemMessage(events); - expect(result?.dynamicContext).not.toContain("super-secret-value"); - expect(result?.dynamicContext).toContain("MY_API_KEY="); + 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.tsx b/src/components/features/conversation-panel/system-message-modal.tsx index f3f0edfbb..c4a87b7e0 100644 --- a/src/components/features/conversation-panel/system-message-modal.tsx +++ b/src/components/features/conversation-panel/system-message-modal.tsx @@ -1,11 +1,8 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; import { SystemMessageHeader } from "./system-message-modal/system-message-header"; -import { - TabNavigation, - SystemMessageTab, -} from "./system-message-modal/tab-navigation"; +import { TabNavigation } from "./system-message-modal/tab-navigation"; import { TabContent } from "./system-message-modal/tab-content"; import { SystemMessageForModal } from "#/utils/system-message-adapter"; @@ -20,19 +17,11 @@ export function SystemMessageModal({ onClose, systemMessage, }: SystemMessageModalProps) { - const [activeTab, setActiveTab] = useState("system"); + const [activeTab, setActiveTab] = useState<"system" | "tools">("system"); const [expandedTools, setExpandedTools] = useState>( {}, ); - // Reset to System on open so a previously selected, now-hidden tab can't - // leave the modal showing an empty panel. - useEffect(() => { - if (isOpen) { - setActiveTab("system"); - } - }, [isOpen]); - if (!systemMessage) { return null; } @@ -62,7 +51,6 @@ export function SystemMessageModal({ 0) } 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 c148c75f4..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,11 +1,10 @@ import { SystemMessageContent } from "./system-message-content"; import { ToolsList } from "./tools-list"; import { EmptyToolsState } from "./empty-tools-state"; -import { SystemMessageTab } from "./tab-navigation"; import { SystemMessageForModal } from "#/utils/system-message-adapter"; interface TabContentProps { - activeTab: SystemMessageTab; + activeTab: "system" | "tools"; systemMessage: SystemMessageForModal; expandedTools: Record; onToggleTool: (index: number) => void; @@ -21,12 +20,6 @@ export function TabContent({ return ; } - if (activeTab === "dynamic") { - return ( - - ); - } - if (activeTab === "tools") { if (systemMessage.tools && systemMessage.tools.length > 0) { return ( diff --git a/src/components/features/conversation-panel/system-message-modal/tab-navigation.tsx b/src/components/features/conversation-panel/system-message-modal/tab-navigation.tsx index f913d6be6..c8522b138 100644 --- a/src/components/features/conversation-panel/system-message-modal/tab-navigation.tsx +++ b/src/components/features/conversation-panel/system-message-modal/tab-navigation.tsx @@ -1,19 +1,15 @@ import { useTranslation } from "react-i18next"; import { TabButton } from "./tab-button"; -export type SystemMessageTab = "system" | "dynamic" | "tools"; - interface TabNavigationProps { - activeTab: SystemMessageTab; - onTabChange: (tab: SystemMessageTab) => void; - hasDynamicContext: boolean; + activeTab: "system" | "tools"; + onTabChange: (tab: "system" | "tools") => void; hasTools: boolean; } export function TabNavigation({ activeTab, onTabChange, - hasDynamicContext, hasTools, }: TabNavigationProps) { const { t } = useTranslation("openhands"); @@ -29,14 +25,6 @@ export function TabNavigation({ > {t("SYSTEM_MESSAGE_MODAL$SYSTEM_MESSAGE_TAB")} - {hasDynamicContext && ( - onTabChange("dynamic")} - > - {t("SYSTEM_MESSAGE_MODAL$DYNAMIC_CONTEXT_TAB")} - - )} {hasTools && ( [] | null; openhands_version: string | null; agent_class: string | null; @@ -20,13 +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, - dynamicContext: dynamicContextText - ? redactCustomSecrets(dynamicContextText) - : null, + content, tools: systemPromptEvent.tools ?? null, openhands_version: null, agent_class: null,