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
3 changes: 3 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,10 @@ const App = () => {
request: {
id: nextRequestId.current,
message: request.params.message,
mode: request.params.mode,
requestedSchema: request.params.requestedSchema,
url: request.params.url,
elicitationId: request.params.elicitationId,
},
originatingTab: currentTab,
resolve,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
PendingElicitationRequest,
FormElicitationRequestData,
ElicitationResponse,
} from "./ElicitationTab";
import Ajv from "ajv";

export type ElicitationRequestProps = {
request: PendingElicitationRequest;
export type ElicitationFormRequestProps = {
request: PendingElicitationRequest & {
request: FormElicitationRequestData;
};
onResolve: (id: number, response: ElicitationResponse) => void;
};

const ElicitationRequest = ({
const ElicitationFormRequest = ({
request,
onResolve,
}: ElicitationRequestProps) => {
}: ElicitationFormRequestProps) => {
const [formData, setFormData] = useState<JsonValue>({});
const [validationError, setValidationError] = useState<string | null>(null);

Expand Down Expand Up @@ -170,4 +173,4 @@ const ElicitationRequest = ({
);
};

export default ElicitationRequest;
export default ElicitationFormRequest;
91 changes: 81 additions & 10 deletions client/src/components/ElicitationTab.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { JsonSchemaType } from "@/utils/jsonUtils";
import ElicitationRequest from "./ElicitationRequest";
import ElicitationFormRequest from "@/components/ElicitationFormRequest.tsx";
import ElicitationUrlRequest from "@/components/ElicitationUrlRequest.tsx";

export interface ElicitationRequestData {
export type FormElicitationRequestData = {
mode?: "form";
id: number;
message: string;
requestedSchema: JsonSchemaType;
}
};

export type UrlElicitationRequestData = {
mode: "url";
id: number;
message: string;
url: string;
elicitationId: string;
};

export type ElicitationRequestData =
| FormElicitationRequestData
| UrlElicitationRequestData;

export interface ElicitationResponse {
action: "accept" | "decline" | "cancel";
Expand All @@ -25,6 +40,23 @@ export type Props = {
onResolve: (id: number, response: ElicitationResponse) => void;
};

const isFormRequest = (
req: PendingElicitationRequest,
): req is PendingElicitationRequest & {
request: FormElicitationRequestData;
} => {
const mode = req.request.mode;
return mode === undefined || mode === null || mode === "form";
};

const isUrlElicitationRequest = (
req: PendingElicitationRequest,
): req is PendingElicitationRequest & {
request: UrlElicitationRequestData;
} => {
return req.request.mode === "url";
};

const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
return (
<TabsContent value="elicitations">
Expand All @@ -37,13 +69,52 @@ const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<ElicitationRequest
key={request.id}
request={request}
onResolve={onResolve}
/>
))}
{pendingRequests.map((request) => {
if (isFormRequest(request)) {
return (
<ElicitationFormRequest
key={request.id}
request={request}
onResolve={onResolve}
/>
);
} else if (isUrlElicitationRequest(request)) {
return (
<ElicitationUrlRequest
key={request.id}
request={request}
onResolve={onResolve}
/>
);
}
return (
<div
key={request.id}
className="flex flex-col gap-3 p-4 border rounded-lg"
>
<p className="text-sm">
Unsupported elicitation mode. You can decline or cancel this
request.
</p>
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
onClick={() => onResolve(request.id, { action: "decline" })}
>
Decline
</Button>
<Button
type="button"
variant="outline"
onClick={() => onResolve(request.id, { action: "cancel" })}
>
Cancel
</Button>
</div>
</div>
);
})}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
Expand Down
165 changes: 165 additions & 0 deletions client/src/components/ElicitationUrlRequest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like CI is failing due to formatting issues, please run npx prettier --write client/src/components/ElicitationUrlRequest.tsx to resolve.

ElicitationResponse,
PendingElicitationRequest,
UrlElicitationRequestData,
} from "@/components/ElicitationTab.tsx";
import JsonView from "@/components/JsonView.tsx";
import { Button } from "@/components/ui/button.tsx";
import { CheckCheck, Copy } from "lucide-react";
import useCopy from "@/lib/hooks/useCopy.ts";
import { toast } from "@/lib/hooks/useToast.ts";

export type ElicitationUrlRequestProps = {
request: PendingElicitationRequest & {
request: UrlElicitationRequestData;
};
onResolve: (id: number, response: ElicitationResponse) => void;
};

const ElicitationUrlRequest = ({
request,
onResolve,
}: ElicitationUrlRequestProps) => {
const { copied, setCopied } = useCopy();

const parsedUrl = (() => {
try {
return new URL(request.request.url);
} catch {
return null;
}
})();

const handleAcceptAndOpen = () => {
if (!parsedUrl) {
return;
}

Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While window.open includes "noopener,noreferrer" security flags (good practice), there's no validation to prevent opening potentially dangerous URL schemes like "javascript:", "data:", or "file:". Consider adding scheme validation to only allow http: and https: protocols before opening the URL, even though the warning system alerts users about non-HTTPS URLs.

Suggested change
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return;
}

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @pbezglasny , looks like you resolved this but the handleAcceptAndOpen function still doesn't validate URL schemes before opening. Should we add the suggested filter for only allowing http or https?

window.open(parsedUrl.href, "_blank", "noopener,noreferrer");

onResolve(request.id, {
action: "accept",
});
};

const handleAccept = () => {
onResolve(request.id, {
action: "accept",
});
};

const handleDecline = () => {
onResolve(request.id, { action: "decline" });
};

const handleCancel = () => {
onResolve(request.id, { action: "cancel" });
};

const warnings = (() => {
if (!parsedUrl) {
return [];
}

const warnings: string[] = [];

if (parsedUrl.protocol !== "https:") {
warnings.push("Not HTTPS protocol");
}

if (parsedUrl.hostname.includes("xn--")) {
warnings.push("This URL contains internationalized (non-ASCII) characters");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the warning message text doesn't match between code and tests:

  • Code (ElicitationUrlRequest.tsx:73): "This URL contains internationalized (non-ASCII) characters"
  • Tests: "This URL contains internationalized characters"

Please update one to match the other.

}
return warnings;
})();

const domain = (() => {
if (parsedUrl) {
return parsedUrl.hostname;
}
console.error("Invalid URL in elicitation request.");
return "Invalid URL";
})();

return (
<div
data-testid="elicitation-request"
className="flex gap-4 p-4 border rounded-lg space-y-4"
>
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<div className="space-y-2">
<div className="mt-2">
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
<JsonView
data={JSON.stringify(
request.request,
["message", "url", "elicitationId"],
2,
)}
/>
</div>
</div>
</div>

<div className="flex-1 space-y-6">
<div className="space-y-3">
{warnings.length > 0 &&
warnings.map((msg, index) => (
<div
key={index}
className="bg-yellow-100 border-l-4 border-yellow-500 p-2 text-xs text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200"
>
{msg}
</div>
))}
<p className="text-sm">{request.request.message}</p>
<p className="text-sm font-semibold">Domain: {domain}</p>
<p className="text-xs text-gray-600">
Full URL: {request.request.url}
</p>
</div>
<div className="flex space-x-2">
<Button
type="button"
onClick={handleAcceptAndOpen}
disabled={!parsedUrl}
>
Accept and open
</Button>
<Button type="button" onClick={handleAccept}>
Accept
</Button>
<Button type="button" variant="outline" onClick={handleDecline}>
Decline
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(request.request.url);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error copying url to the clipboard: ${error instanceof Error ? error.message : String(error)}`,
});
}
}}
>
{copied ? (
<CheckCheck className="h-4 w-4 mr-2 dark:text-green-700 text-green-600" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy URL
</Button>
</div>
</div>
</div>
);
};

export default ElicitationUrlRequest;
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach, afterEach } from "@jest/globals";
import ElicitationRequest from "../ElicitationRequest";
import { PendingElicitationRequest } from "../ElicitationTab";
import ElicitationFormRequest from "../ElicitationFormRequest";
import {
FormElicitationRequestData,
PendingElicitationRequest,
} from "../ElicitationTab";

jest.mock("../DynamicJsonForm", () => {
return function MockDynamicJsonForm({
Expand Down Expand Up @@ -38,6 +41,10 @@ jest.mock("../DynamicJsonForm", () => {
describe("ElicitationRequest", () => {
const mockOnResolve = jest.fn();

type FormPendingElicitationRequest = PendingElicitationRequest & {
request: FormElicitationRequestData;
};

beforeEach(() => {
jest.clearAllMocks();
});
Expand All @@ -47,8 +54,8 @@ describe("ElicitationRequest", () => {
});

const createMockRequest = (
overrides: Partial<PendingElicitationRequest> = {},
): PendingElicitationRequest => ({
overrides: Partial<FormPendingElicitationRequest> = {},
): FormPendingElicitationRequest => ({
id: 1,
request: {
id: 1,
Expand All @@ -66,10 +73,10 @@ describe("ElicitationRequest", () => {
});

const renderElicitationRequest = (
request: PendingElicitationRequest = createMockRequest(),
request: FormPendingElicitationRequest = createMockRequest(),
) => {
return render(
<ElicitationRequest request={request} onResolve={mockOnResolve} />,
<ElicitationFormRequest request={request} onResolve={mockOnResolve} />,
);
};

Expand Down
Loading
Loading