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 <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-03-19 17:55:27 +00:00
parent b595448125
commit ff01f3e7be
4 changed files with 145 additions and 14 deletions

View File

@@ -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(() => {

View File

@@ -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({
)}
</div>
<div className="flex w-full justify-end gap-2 pt-4 border-t border-tertiary">
<BrandButton
testId="start-conversation-button"
type="button"
variant="primary"
onClick={handleStartConversation}
isDisabled={isLoading}
className="px-4"
>
{isLoading
? t(I18nKey.LAUNCH$STARTING)
: t(I18nKey.LAUNCH$START_CONVERSATION)}
</BrandButton>
<div className="pt-4 border-t border-tertiary">
<div className="flex items-start gap-3 mb-4">
<input
id="trust-checkbox"
data-testid="trust-checkbox"
type="checkbox"
checked={trustConfirmed}
onChange={(e) => setTrustConfirmed(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-tertiary bg-base-secondary accent-primary flex-shrink-0"
/>
<label htmlFor="trust-checkbox" className="text-sm text-white">
{t(I18nKey.LAUNCH$TRUST_SKILL_CHECKBOX, {
sources: getUniqueSources().join(", "),
})}
</label>
</div>
<div className="flex w-full justify-end gap-2">
<BrandButton
testId="start-conversation-button"
type="button"
variant="primary"
onClick={handleStartConversation}
isDisabled={isLoading || !trustConfirmed}
className="px-4"
>
{isLoading
? t(I18nKey.LAUNCH$STARTING)
: t(I18nKey.LAUNCH$START_CONVERSATION)}
</BrandButton>
</div>
</div>
</div>
</ModalBackdrop>

View File

@@ -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",

View File

@@ -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": "あなたの役割は?",