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,