Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions __tests__/utils/redact-custom-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -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 =
"<CUSTOM_SECRETS>\nMY_API_KEY=<secret-hidden>\n</CUSTOM_SECRETS>";
expect(redactCustomSecrets(text)).toBe(text);
});

it("redacts an unmasked value inside the block (= separator)", () => {
const text = "<CUSTOM_SECRETS>\nMY_API_KEY=leaked-value\n</CUSTOM_SECRETS>";
const result = redactCustomSecrets(text);
expect(result).not.toContain("leaked-value");
expect(result).toContain("MY_API_KEY=<secret-hidden>");
});

it("redacts an unmasked value inside the block (: separator)", () => {
const text =
"<CUSTOM_SECRETS>\nMY_API_KEY: leaked-value\n</CUSTOM_SECRETS>";
const result = redactCustomSecrets(text);
expect(result).not.toContain("leaked-value");
expect(result).toContain("MY_API_KEY: <secret-hidden>");
});

it("redacts only inside the block, leaving surrounding text untouched", () => {
const text =
"<CURRENT_DATETIME>2026-06-01</CURRENT_DATETIME>\n" +
"<CUSTOM_SECRETS>\nTOKEN=abc123\n</CUSTOM_SECRETS>\n" +
"key=value-outside-block";
const result = redactCustomSecrets(text);
expect(result).toContain("<CURRENT_DATETIME>2026-06-01</CURRENT_DATETIME>");
expect(result).toContain("key=value-outside-block");
expect(result).not.toContain("abc123");
expect(result).toContain("TOKEN=<secret-hidden>");
});

it("returns text unchanged when there is no CUSTOM_SECRETS block", () => {
const text = "<SKILLS>my-skill</SKILLS>\nkey=value";
expect(redactCustomSecrets(text)).toBe(text);
});

it("redacts multiple secrets within the block", () => {
const text =
"<CUSTOM_SECRETS>\nA=one\nB=<secret-hidden>\nC=three\n</CUSTOM_SECRETS>";
const result = redactCustomSecrets(text);
expect(result).not.toContain("one");
expect(result).not.toContain("three");
expect(result).toContain("A=<secret-hidden>");
expect(result).toContain("B=<secret-hidden>");
expect(result).toContain("C=<secret-hidden>");
});

it("redacts even when the closing tag is missing (truncated block)", () => {
const text = "<CUSTOM_SECRETS>\nMY_API_KEY=leaked-value";
const result = redactCustomSecrets(text);
expect(result).not.toContain("leaked-value");
expect(result).toContain("MY_API_KEY=<secret-hidden>");
});

it("stays idempotent across repeated calls", () => {
const text = "<CUSTOM_SECRETS>\nTOKEN=leaked-value\n</CUSTOM_SECRETS>";
const once = redactCustomSecrets(text);
const twice = redactCustomSecrets(once);
expect(twice).toBe(once);
expect(twice).not.toContain("leaked-value");
});
});
39 changes: 39 additions & 0 deletions __tests__/utils/system-message-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<SKILLS>my-skill</SKILLS>" },
},
];
const result = adaptSystemMessage(events);
expect(result?.dynamicContext).toBe("<SKILLS>my-skill</SKILLS>");
});

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: "<CUSTOM_SECRETS>\nMY_API_KEY=super-secret-value\n</CUSTOM_SECRETS>",
},
},
];
const result = adaptSystemMessage(events);
expect(result?.dynamicContext).not.toContain("super-secret-value");
expect(result?.dynamicContext).toContain("MY_API_KEY=<secret-hidden>");
});
});
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -17,11 +20,19 @@ export function SystemMessageModal({
onClose,
systemMessage,
}: SystemMessageModalProps) {
const [activeTab, setActiveTab] = useState<"system" | "tools">("system");
const [activeTab, setActiveTab] = useState<SystemMessageTab>("system");
const [expandedTools, setExpandedTools] = useState<Record<number, boolean>>(
{},
);

// 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;
}
Expand Down Expand Up @@ -51,6 +62,7 @@ export function SystemMessageModal({
<TabNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
hasDynamicContext={!!systemMessage.dynamicContext}
hasTools={
!!(systemMessage.tools && systemMessage.tools.length > 0)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> | ChatCompletionToolParam[] | null;
};
activeTab: SystemMessageTab;
systemMessage: SystemMessageForModal;
expandedTools: Record<number, boolean>;
onToggleTool: (index: number) => void;
}
Expand All @@ -23,6 +21,12 @@ export function TabContent({
return <SystemMessageContent content={systemMessage.content} />;
}

if (activeTab === "dynamic") {
return (
<SystemMessageContent content={systemMessage.dynamicContext ?? ""} />
);
}

if (activeTab === "tools") {
if (systemMessage.tools && systemMessage.tools.length > 0) {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -25,6 +29,14 @@ export function TabNavigation({
>
{t("SYSTEM_MESSAGE_MODAL$SYSTEM_MESSAGE_TAB")}
</TabButton>
{hasDynamicContext && (
<TabButton
isActive={activeTab === "dynamic"}
onClick={() => onTabChange("dynamic")}
>
{t("SYSTEM_MESSAGE_MODAL$DYNAMIC_CONTEXT_TAB")}
</TabButton>
)}
{hasTools && (
<TabButton
isActive={activeTab === "tools"}
Expand Down
17 changes: 17 additions & 0 deletions src/i18n/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -17798,6 +17798,23 @@
"uk": "Системне повідомлення",
"ca": "Missatge del sistema"
},
"SYSTEM_MESSAGE_MODAL$DYNAMIC_CONTEXT_TAB": {
"en": "Dynamic Context",
"zh-CN": "动态上下文",
"zh-TW": "動態上下文",
"ko-KR": "동적 컨텍스트",
"ja": "動的コンテキスト",
"no": "Dynamisk kontekst",
"ar": "السياق الديناميكي",
"de": "Dynamischer Kontext",
"fr": "Contexte dynamique",
"it": "Contesto dinamico",
"pt": "Contexto dinâmico",
"es": "Contexto dinámico",
"tr": "Dinamik Bağlam",
"uk": "Динамічний контекст",
"ca": "Context dinàmic"
},
"SYSTEM_MESSAGE_MODAL$TOOLS_TAB": {
"en": "Available Tools",
"zh-CN": "可用工具",
Expand Down
6 changes: 6 additions & 0 deletions src/types/agent-server/core/events/system-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
29 changes: 29 additions & 0 deletions src/utils/redact-custom-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const MASKED_PLACEHOLDER = "<secret-hidden>";

// Closing tag is optional so a truncated block is still redacted, not leaked.
const CUSTOM_SECRETS_BLOCK =
/(<CUSTOM_SECRETS>)([\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 `<CUSTOM_SECRETS>`
* 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}`;
},
);
}
7 changes: 7 additions & 0 deletions src/utils/system-message-adapter.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] | null;
openhands_version: string | null;
agent_class: string | null;
Expand All @@ -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,
Expand Down