From ff01f3e7be60a514844efbf4762f14e593326b63 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 17:55:27 +0000 Subject: [PATCH] feat(frontend): Add trust confirmation checkbox to plugin launch modal - Add checkbox at bottom of launch dialog with text: 'I trust this skill from [org/repo] with the agent secrets defined in my account.' - Disable launch button unless checkbox is checked (unchecked by default) - Display unique source repos in checkbox label (e.g., owner/repo1, owner/repo2) - Add LAUNCH$TRUST_SKILL_CHECKBOX translation key in 14 languages - Update tests to verify checkbox behavior Addresses security concern raised in PR review. Co-authored-by: openhands --- frontend/__tests__/routes/launch.test.tsx | 93 ++++++++++++++++++- .../features/launch/plugin-launch-modal.tsx | 49 +++++++--- frontend/src/i18n/declaration.ts | 1 + frontend/src/i18n/translation.json | 16 ++++ 4 files changed, 145 insertions(+), 14 deletions(-) diff --git a/frontend/__tests__/routes/launch.test.tsx b/frontend/__tests__/routes/launch.test.tsx index d7d0054d4b..3f52a3c947 100644 --- a/frontend/__tests__/routes/launch.test.tsx +++ b/frontend/__tests__/routes/launch.test.tsx @@ -319,8 +319,72 @@ describe("LaunchRoute", () => { }); }); + describe("Trust Checkbox", () => { + it("should display trust checkbox unchecked by default", () => { + const plugins = [{ source: "github:owner/repo" }]; + const encoded = btoa(JSON.stringify(plugins)); + + renderLaunchRoute(`?plugins=${encoded}`); + + const checkbox = screen.getByTestId("trust-checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + + it("should display trust checkbox with associated label", () => { + const plugins = [{ source: "github:owner/repo" }]; + const encoded = btoa(JSON.stringify(plugins)); + + renderLaunchRoute(`?plugins=${encoded}`); + + // Check that the checkbox has a label associated with it + const checkbox = screen.getByTestId("trust-checkbox"); + const label = document.querySelector('label[for="trust-checkbox"]'); + expect(label).toBeInTheDocument(); + }); + + it("should have trust checkbox label that references the translation key", () => { + const plugins = [ + { source: "github:owner/repo1" }, + { source: "github:owner/repo2" }, + ]; + const encoded = btoa(JSON.stringify(plugins)); + + renderLaunchRoute(`?plugins=${encoded}`); + + // In test environment, the translation key is shown + const label = document.querySelector('label[for="trust-checkbox"]'); + expect(label).toBeInTheDocument(); + expect(label?.textContent).toContain("LAUNCH$TRUST_SKILL_CHECKBOX"); + }); + + it("should disable start button when trust checkbox is unchecked", () => { + const plugins = [{ source: "github:owner/repo" }]; + const encoded = btoa(JSON.stringify(plugins)); + + renderLaunchRoute(`?plugins=${encoded}`); + + const button = screen.getByTestId("start-conversation-button"); + expect(button).toBeDisabled(); + }); + + it("should enable start button when trust checkbox is checked", async () => { + const user = userEvent.setup(); + const plugins = [{ source: "github:owner/repo" }]; + const encoded = btoa(JSON.stringify(plugins)); + + renderLaunchRoute(`?plugins=${encoded}`); + + const checkbox = screen.getByTestId("trust-checkbox"); + await user.click(checkbox); + + const button = screen.getByTestId("start-conversation-button"); + expect(button).not.toBeDisabled(); + }); + }); + describe("Conversation Creation", () => { - it("should call createConversation with plugins when start button clicked", async () => { + it("should call createConversation with plugins when start button clicked after checking trust", async () => { const user = userEvent.setup(); const plugins = [ { source: "github:owner/repo", parameters: { apiKey: "test" } }, @@ -329,6 +393,8 @@ describe("LaunchRoute", () => { renderLaunchRoute(`?plugins=${encoded}`); + // First check the trust checkbox + await user.click(screen.getByTestId("trust-checkbox")); await user.click(screen.getByTestId("start-conversation-button")); await waitFor(() => { @@ -354,6 +420,8 @@ describe("LaunchRoute", () => { renderLaunchRoute(`?plugins=${encoded}&message=${encodeURIComponent(message)}`); + // First check the trust checkbox + await user.click(screen.getByTestId("trust-checkbox")); await user.click(screen.getByTestId("start-conversation-button")); await waitFor(() => { @@ -380,12 +448,31 @@ describe("LaunchRoute", () => { renderLaunchRoute(`?plugins=${encoded}`); + // First check the trust checkbox + await user.click(screen.getByTestId("trust-checkbox")); await user.click(screen.getByTestId("start-conversation-button")); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith("/conversations/new-conv-456"); }); }); + + it("should not call createConversation when trust checkbox is not checked", async () => { + const user = userEvent.setup(); + const plugins = [{ source: "github:owner/repo" }]; + const encoded = btoa(JSON.stringify(plugins)); + + renderLaunchRoute(`?plugins=${encoded}`); + + // Try to click without checking trust (button should be disabled) + const button = screen.getByTestId("start-conversation-button"); + expect(button).toBeDisabled(); + + // Even attempting to click shouldn't call the mutation + await user.click(button); + + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); }); describe("Error Handling", () => { @@ -414,6 +501,8 @@ describe("LaunchRoute", () => { renderLaunchRoute(`?plugins=${encoded}`); + // First check the trust checkbox + await user.click(screen.getByTestId("trust-checkbox")); await user.click(screen.getByTestId("start-conversation-button")); await waitFor(() => { @@ -431,6 +520,8 @@ describe("LaunchRoute", () => { renderLaunchRoute(`?plugins=${encoded}`); + // First check the trust checkbox + await user.click(screen.getByTestId("trust-checkbox")); await user.click(screen.getByTestId("start-conversation-button")); await waitFor(() => { diff --git a/frontend/src/components/features/launch/plugin-launch-modal.tsx b/frontend/src/components/features/launch/plugin-launch-modal.tsx index 6b5d7e3c07..76390572d3 100644 --- a/frontend/src/components/features/launch/plugin-launch-modal.tsx +++ b/frontend/src/components/features/launch/plugin-launch-modal.tsx @@ -42,6 +42,7 @@ export function PluginLaunchModal({ return initial; }, ); + const [trustConfirmed, setTrustConfirmed] = React.useState(false); const pluginsWithParams = pluginConfigs.filter( (p) => p.parameters && Object.keys(p.parameters).length > 0, @@ -106,6 +107,11 @@ export function PluginLaunchModal({ return source; }; + const getUniqueSources = (): string[] => { + const sources = pluginConfigs.map((plugin) => getPluginSourceInfo(plugin)); + return [...new Set(sources)]; + }; + const handleStartConversation = () => { onStartConversation(pluginConfigs, message); }; @@ -305,19 +311,36 @@ export function PluginLaunchModal({ )} -
- - {isLoading - ? t(I18nKey.LAUNCH$STARTING) - : t(I18nKey.LAUNCH$START_CONVERSATION)} - +
+
+ setTrustConfirmed(e.target.checked)} + className="mt-1 h-4 w-4 rounded border-tertiary bg-base-secondary accent-primary flex-shrink-0" + /> + +
+
+ + {isLoading + ? t(I18nKey.LAUNCH$STARTING) + : t(I18nKey.LAUNCH$START_CONVERSATION)} + +
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index f05b39249a..a67434130f 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1037,6 +1037,7 @@ export enum I18nKey { LAUNCH$TRY_AGAIN = "LAUNCH$TRY_AGAIN", LAUNCH$PLUGIN_REF = "LAUNCH$PLUGIN_REF", LAUNCH$PLUGIN_PATH = "LAUNCH$PLUGIN_PATH", + LAUNCH$TRUST_SKILL_CHECKBOX = "LAUNCH$TRUST_SKILL_CHECKBOX", ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE", ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE", ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 21445b66c2..9828052a62 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -16595,6 +16595,22 @@ "de": "path:", "uk": "path:" }, + "LAUNCH$TRUST_SKILL_CHECKBOX": { + "en": "I trust this skill from {{sources}} with the agent secrets defined in my account.", + "ja": "私は{{sources}}からのこのスキルを、アカウントに定義されたエージェントシークレットとともに信頼します。", + "zh-CN": "我信任来自 {{sources}} 的此技能,并允许其使用我账户中定义的代理密钥。", + "zh-TW": "我信任來自 {{sources}} 的此技能,並允許其使用我帳戶中定義的代理密鑰。", + "ko-KR": "{{sources}}의 이 스킬을 내 계정에 정의된 에이전트 비밀과 함께 신뢰합니다.", + "no": "Jeg stoler på denne ferdigheten fra {{sources}} med agenthemmelighetene definert i kontoen min.", + "it": "Mi fido di questa skill da {{sources}} con i segreti dell'agente definiti nel mio account.", + "pt": "Eu confio nesta habilidade de {{sources}} com os segredos do agente definidos em minha conta.", + "es": "Confío en esta habilidad de {{sources}} con los secretos del agente definidos en mi cuenta.", + "ar": "أثق في هذه المهارة من {{sources}} مع أسرار الوكيل المحددة في حسابي.", + "fr": "Je fais confiance à cette compétence de {{sources}} avec les secrets d'agent définis dans mon compte.", + "tr": "{{sources}} kaynağından gelen bu beceriye hesabımda tanımlı ajan sırlarıyla güveniyorum.", + "de": "Ich vertraue dieser Fähigkeit von {{sources}} mit den in meinem Konto definierten Agenten-Geheimnissen.", + "uk": "Я довіряю цій навичці з {{sources}} із секретами агента, визначеними в моєму обліковому записі." + }, "ONBOARDING$STEP1_TITLE": { "en": "What's your role?", "ja": "あなたの役割は?",