From 1618617ea9f2b758f7ff95b8eb226579f739e522 Mon Sep 17 00:00:00 2001 From: milan Date: Fri, 31 Jul 2026 21:47:22 +0000 Subject: [PATCH 1/8] feat(ui): accept ssh clone urls when registering a skill Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../skills/_components/add_plugin_form.tsx | 2 +- .../claude_code_plugins/helpers.test.ts | 43 +++++++++++++++ .../components/claude_code_plugins/helpers.ts | 52 +++++++++++++++++-- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx index 04b8c88ae8d..226f9d19837 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx @@ -167,7 +167,7 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT label="Repository URL" name="skillUrl" rules={[{ required: true, message: "Please enter a repository URL" }]} - tooltip="Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host. E.g. github.com/org/repo, gitlab.com/org/repo, or github.com/org/repo/tree/main/my-skill" + tooltip="Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host, e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key" > { it("returns null when no repo or url", () => { expect(getSourceLink({ source: "github" })).toBeNull(); }); + + it("returns null for an ssh clone url, which is not browsable", () => { + expect(getSourceLink({ source: "url", url: "git@ghe.example.com:org/repo.git" })).toBeNull(); + expect(getSourceLink({ source: "url", url: "ssh://git@ghe.example.com/org/repo.git" })).toBeNull(); + }); }); describe("getCategoryBadgeColor", () => { @@ -455,6 +460,44 @@ describe("parseSkillSource", () => { expect(parseSkillSource("gitlab.com/org/repo", "a//b")).toBeNull(); }); + it("keeps an scp-style ssh clone url so private hosts authenticate with the user's key", () => { + expect(parseSkillSource("git@ghe.example.com:org/repo.git")?.parsed).toEqual({ + source: "url", + url: "git@ghe.example.com:org/repo.git", + }); + expect(parseSkillSource("git@ghe.example.com:org/repo")?.parsed).toEqual({ + source: "url", + url: "git@ghe.example.com:org/repo.git", + }); + expect(parseSkillSource("git@ghe.example.com:org/repo.git")?.suggestedName).toBe("repo"); + }); + + it("normalizes an ssh:// clone url and keeps a custom port", () => { + expect(parseSkillSource("ssh://git@ghe.example.com/org/repo")?.parsed).toEqual({ + source: "url", + url: "ssh://git@ghe.example.com/org/repo.git", + }); + expect(parseSkillSource("ssh://git@ghe.example.com:2222/org/nested/repo.git")?.parsed).toEqual({ + source: "url", + url: "ssh://git@ghe.example.com:2222/org/nested/repo.git", + }); + }); + + it("combines an ssh clone url with an explicit subfolder", () => { + expect(parseSkillSource("git@ghe.example.com:org/repo.git", "plugins/my-skill")?.parsed).toEqual({ + source: "git-subdir", + url: "git@ghe.example.com:org/repo.git", + path: "plugins/my-skill", + }); + expect(parseSkillSource("git@ghe.example.com:org/repo.git", "../etc")).toBeNull(); + }); + + it("rejects ssh-looking input without a host or repo path", () => { + expect(parseSkillSource("git@ghe.example.com:repo.git")).toBeNull(); + expect(parseSkillSource("git@localhost:org/repo.git")).toBeNull(); + expect(parseSkillSource("git@:org/repo.git")).toBeNull(); + }); + it("returns null for empty and garbage input", () => { expect(parseSkillSource("")).toBeNull(); expect(parseSkillSource(" ")).toBeNull(); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts index a4e70f78af1..caf59c71b76 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -30,6 +30,10 @@ const IPV4_HOST_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/; const GITHUB_ORG_REGEX = /^[A-Za-z0-9-]+$/; const GITHUB_REPO_REGEX = /^[A-Za-z0-9._-]+$/; +const SSH_SCP_REGEX = /^([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,}):([a-z0-9._-]+(?:\/[a-z0-9._-]+)+?)(?:\.git)?\/?$/i; +const SSH_URL_REGEX = + /^ssh:\/\/([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,})(:\d+)?\/([a-z0-9._-]+(?:\/[a-z0-9._-]+)+?)(?:\.git)?\/?$/i; + const buildRepoUrl = (url: URL): string => `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`; const pathSegments = (url: URL): string[] => url.pathname.split("/").filter((seg) => seg !== ""); @@ -160,12 +164,54 @@ const parseRawGitSource = (url: URL, subPath?: string): SkillSourcePreview | nul }; }; +const withGitSuffix = (path: string): string => `${path.replace(/\.git$/i, "")}.git`; + +const parseSshRepoUrl = (raw: string): string | null => { + const trimmed = raw.trim(); + const sshUrl = SSH_URL_REGEX.exec(trimmed); + if (sshUrl) { + const [, user, host, port, path] = sshUrl; + return `ssh://${user}@${host}${port ?? ""}/${withGitSuffix(path)}`; + } + const scp = SSH_SCP_REGEX.exec(trimmed); + if (scp) { + const [, user, host, path] = scp; + return `${user}@${host}:${withGitSuffix(path)}`; + } + return null; +}; + +const parseSshSource = (cloneUrl: string, subPath?: string): SkillSourcePreview | null => { + const repoName = lastSegment(cloneUrl.replace(/\.git$/, "").replace(/^[^:]*:/, "")); + const normalized = normalizeSubPath(subPath ?? ""); + if (normalized !== "") { + if (!SUBDIR_PATH_REGEX.test(normalized)) { + return null; + } + return { + parsed: { source: "git-subdir", url: cloneUrl, path: normalized }, + label: `SSH subdir — ${cloneUrl} @ ${normalized}`, + suggestedName: toKebabCase(lastSegment(normalized)), + }; + } + return { + parsed: { source: "url", url: cloneUrl }, + label: `SSH repo — ${cloneUrl}`, + suggestedName: toKebabCase(repoName), + }; +}; + /** * Parse any git-accessible repository URL into a registerable skill source. - * GitHub URLs keep their `github`/`git-subdir` shorthand; every other host is - * treated as a raw repo URL, with an optional subfolder turning it into git-subdir. + * GitHub https URLs keep their `github`/`git-subdir` shorthand; ssh clone URLs stay ssh so a + * private host authenticates with the user's own key; every other host is treated as a raw repo + * URL, with an optional subfolder turning it into git-subdir. */ export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourcePreview | null => { + const sshCloneUrl = parseSshRepoUrl(rawUrl); + if (sshCloneUrl) { + return parseSshSource(sshCloneUrl, subPath); + } const url = parseRepoUrl(rawUrl); if (!url) { return null; @@ -268,7 +314,7 @@ export const getSourceLink = (source: PluginSource): string | null => { return `https://github.com/${source.repo}`; } if ((source.source === "url" || source.source === "git-subdir") && source.url) { - return source.url; + return source.url.startsWith("https://") ? source.url : null; } return null; }; From 66e1ea6090e5e97a5fa6c17aba376a1d57bc6e66 Mon Sep 17 00:00:00 2001 From: milan Date: Fri, 31 Jul 2026 21:58:27 +0000 Subject: [PATCH 2/8] fix(ui): render non-browsable skill sources as text on the detail page Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../claude_code_plugins/skill_detail.test.tsx | 42 +++++++++++++++++++ .../claude_code_plugins/skill_detail.tsx | 21 +++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx new file mode 100644 index 00000000000..98ae4a44ae6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { Plugin } from "./types"; + +import SkillDetail from "./skill_detail"; + +const buildSkill = (source: Plugin["source"]): Plugin => ({ + id: "plugin-id", + name: "my-skill", + source, + enabled: true, +}); + +describe("SkillDetail source", () => { + it("links a github source to the repository", () => { + render(); + expect(screen.getByRole("link", { name: /github.com\/org\/repo/ })).toHaveAttribute( + "href", + "https://github.com/org/repo", + ); + }); + + it("renders an ssh clone url as plain text instead of an unusable link", () => { + render( + , + ); + expect(screen.getByText("git@ghe.example.com:org/repo.git")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /ghe.example.com/ })).not.toBeInTheDocument(); + }); + + it("renders an ssh git-subdir source as plain text without a tree path", () => { + render( + , + ); + expect(screen.getByText("git@ghe.example.com:org/repo.git")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /ghe.example.com/ })).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index fe001641135..0f25b8ad515 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { ArrowLeftOutlined, CopyOutlined, CheckOutlined, LinkOutlined } from "@ant-design/icons"; -import { buildMarketplaceSettingsSnippet, formatInstallCommand } from "./helpers"; +import { buildMarketplaceSettingsSnippet, formatInstallCommand, getSourceLink } from "./helpers"; import { Plugin } from "./types"; interface SkillDetailProps { @@ -21,13 +21,15 @@ const SkillDetail: React.FC = ({ skill, onBack }) => { setTimeout(() => setCopiedKey(null), 2000); }; + const sourceLink = getSourceLink(skill.source); const sourceUrl = (() => { const src = skill.source; - if (src.source === "github" && src.repo) return `https://github.com/${src.repo}`; - if (src.source === "git-subdir" && src.url) return src.path ? `${src.url}/tree/main/${src.path}` : src.url; - if (src.source === "url" && src.url) return src.url; - return null; + if (sourceLink && src.source === "git-subdir" && src.path) { + return `${sourceLink}/tree/main/${src.path}`; + } + return sourceLink; })(); + const sourceText = skill.source.url ?? sourceUrl; const installCommand = formatInstallCommand(skill); @@ -146,7 +148,7 @@ const SkillDetail: React.FC = ({ skill, onBack }) => { - {sourceUrl && ( + {sourceUrl ? ( + ) : ( + sourceText && ( +
+
Source
+
{sourceText}
+
+ ) )} {skill.keywords && skill.keywords.length > 0 && ( From 7d5c5d8873e55cb47b82a351046f2af31be06cf3 Mon Sep 17 00:00:00 2001 From: milan Date: Fri, 31 Jul 2026 22:03:38 +0000 Subject: [PATCH 3/8] fix(ui): show the subfolder path for non-browsable skill sources Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/claude_code_plugins/skill_detail.test.tsx | 2 +- .../src/components/claude_code_plugins/skill_detail.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx index 98ae4a44ae6..2f600397a4b 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx @@ -36,7 +36,7 @@ describe("SkillDetail source", () => { onBack={vi.fn()} />, ); - expect(screen.getByText("git@ghe.example.com:org/repo.git")).toBeInTheDocument(); + expect(screen.getByText("git@ghe.example.com:org/repo.git @ plugins/x")).toBeInTheDocument(); expect(screen.queryByRole("link", { name: /ghe.example.com/ })).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index 0f25b8ad515..ad98fdf232c 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -29,7 +29,9 @@ const SkillDetail: React.FC = ({ skill, onBack }) => { } return sourceLink; })(); - const sourceText = skill.source.url ?? sourceUrl; + const sourceText = skill.source.url + ? `${skill.source.url}${skill.source.path ? ` @ ${skill.source.path}` : ""}` + : sourceUrl; const installCommand = formatInstallCommand(skill); From b3432abef7920773788382709bc160c4c0c7b9e9 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 16 Sep 2026 00:58:03 +0000 Subject: [PATCH 4/8] refactor(ui): derive the ssh skill name from the bare repo path Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/claude_code_plugins/helpers.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts index cbca22da1f3..3c11a6cedbc 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -180,17 +180,15 @@ const parseRawGitSource = (url: URL, subPath?: string): SkillSourcePreview | nul interface SshRemote { cloneUrl: string; - repoPath: string; + repoName: string; } -const withGitSuffix = (path: string): string => `${path.replace(/\.git$/i, "")}.git`; - const buildSshRemote = (rawPath: string, toCloneUrl: (repoPath: string) => string): SshRemote | null => { if (rawPath.split("/").some((segment) => DOTS_ONLY_SEGMENT_REGEX.test(segment))) { return null; } - const repoPath = withGitSuffix(rawPath); - return { cloneUrl: toCloneUrl(repoPath), repoPath }; + const bare = rawPath.replace(/\.git$/i, ""); + return { cloneUrl: toCloneUrl(`${bare}.git`), repoName: lastSegment(bare) }; }; const parseSshRemote = (raw: string): SshRemote | null => { @@ -208,9 +206,6 @@ const parseSshRemote = (raw: string): SshRemote | null => { return null; }; -const parseSshSource = (remote: SshRemote, subPath?: string): SkillSourcePreview | null => - buildGitSourcePreview("SSH", remote.cloneUrl, lastSegment(remote.repoPath).replace(/\.git$/, ""), subPath); - const parseArchiveSource = (url: URL): SkillSourcePreview => ({ parsed: { source: "archive", url: url.href }, label: `Zip archive — ${url.host}${url.pathname}`, @@ -225,9 +220,9 @@ const parseArchiveSource = (url: URL): SkillSourcePreview => ({ * with an optional subfolder turning it into git-subdir. */ export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourcePreview | null => { - const sshRemote = parseSshRemote(rawUrl); - if (sshRemote) { - return parseSshSource(sshRemote, subPath); + const ssh = parseSshRemote(rawUrl); + if (ssh) { + return buildGitSourcePreview("SSH", ssh.cloneUrl, ssh.repoName, subPath); } const url = parseRepoUrl(rawUrl); if (!url) { From ecd72c51da019f30debf182d44f89e2c2b6e4854 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 16 Sep 2026 11:29:25 -0700 Subject: [PATCH 5/8] refactor(ui): register ssh skill sources verbatim and share the https host rules The ssh parser rebuilt the clone url it was given, stripping a trailing .git and appending one back. Git treats that suffix as optional, and the hosts whose clone paths are not org/repo break when it is forced on, so an Azure DevOps v3 or a CodeCommit v1/repos url registered through the form would no longer clone. It also carried its own host pattern, which demanded an alphabetic final label and so rejected internal hosts like gitlab.internal.k8s2 that the https path accepts. Rewrite the scp form into an ssh:// url purely to validate it, reuse the https host and credential checks through a shared isSafeHost, and store exactly what the user typed. Only a url that survives the round trip unchanged is accepted, which is what keeps traversal segments out of the feed, so the two ssh regexes, the dots-only guard and the clone-url builders all collapse into one function. --- .../claude_code_plugins/helpers.test.ts | 36 +++++--- .../components/claude_code_plugins/helpers.ts | 84 +++++++++---------- 2 files changed, 65 insertions(+), 55 deletions(-) diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts index ccdc0a1a388..5bf782aabf3 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts @@ -476,21 +476,30 @@ describe("parseSkillSource", () => { source: "url", url: "git@ghe.example.com:org/repo.git", }); - expect(parseSkillSource("git@ghe.example.com:org/repo")?.parsed).toEqual({ - source: "url", - url: "git@ghe.example.com:org/repo.git", - }); expect(parseSkillSource("git@ghe.example.com:org/repo.git")?.suggestedName).toBe("repo"); }); - it("normalizes an ssh:// clone url and keeps a custom port", () => { - expect(parseSkillSource("ssh://git@ghe.example.com/org/repo")?.parsed).toEqual({ + it("stores an ssh clone url exactly as typed, so a forced .git suffix cannot break azure devops or codecommit", () => { + for (const url of [ + "git@ghe.example.com:org/repo", + "git@ssh.dev.azure.com:v3/org/project/repo", + "ssh://git@ghe.example.com/org/repo", + "ssh://apka1234@git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo", + "ssh://git@ghe.example.com:2222/org/nested/repo.git", + ]) { + expect(parseSkillSource(url)?.parsed).toEqual({ source: "url", url }); + } + expect(parseSkillSource("git@ssh.dev.azure.com:v3/org/project/repo")?.suggestedName).toBe("repo"); + }); + + it("accepts an internal host whose last label is not alphabetic, matching the https rule", () => { + expect(parseSkillSource("git@gitlab.internal.k8s2:org/repo.git")?.parsed).toEqual({ source: "url", - url: "ssh://git@ghe.example.com/org/repo.git", + url: "git@gitlab.internal.k8s2:org/repo.git", }); - expect(parseSkillSource("ssh://git@ghe.example.com:2222/org/nested/repo.git")?.parsed).toEqual({ + expect(parseSkillSource("https://gitlab.internal.k8s2/org/repo")?.parsed).toEqual({ source: "url", - url: "ssh://git@ghe.example.com:2222/org/nested/repo.git", + url: "https://gitlab.internal.k8s2/org/repo", }); }); @@ -510,17 +519,22 @@ describe("parseSkillSource", () => { expect(parseSkillSource("ssh://ghe.example.com/org/repo.git")).toBeNull(); }); - it("rejects ssh remotes with ip hosts or dot-only path segments", () => { + it("rejects ssh remotes with ip hosts or traversal segments", () => { expect(parseSkillSource("git@10.0.0.5:org/repo.git")).toBeNull(); expect(parseSkillSource("ssh://git@169.254.169.254/org/repo")).toBeNull(); expect(parseSkillSource("git@ghe.example.com:../etc")).toBeNull(); expect(parseSkillSource("ssh://git@ghe.example.com/org/../repo")).toBeNull(); + expect(parseSkillSource("git@ghe.example.com:org/../../etc/passwd")).toBeNull(); expect(parseSkillSource("git@ghe.example.com:org/.github")?.parsed).toEqual({ source: "url", - url: "git@ghe.example.com:org/.github.git", + url: "git@ghe.example.com:org/.github", }); }); + it("rejects an ssh remote carrying a password, which would publish a secret on the feed", () => { + expect(parseSkillSource("ssh://git:s3cret@ghe.example.com/org/repo.git")).toBeNull(); + }); + it("returns null for empty and garbage input", () => { expect(parseSkillSource("")).toBeNull(); expect(parseSkillSource(" ")).toBeNull(); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts index 3c11a6cedbc..b4d9eab08ec 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -29,22 +29,33 @@ export const SHA256_REGEX = /^[0-9a-fA-F]{64}$/; export const isValidSha256 = (digest: string): boolean => digest.trim() === "" || SHA256_REGEX.test(digest.trim()); -// WHATWG normalizes obfuscated IPv4 (e.g. 2130706433, 0x7f.0.0.1) to dotted-decimal, so this -// catches every IPv4 form; bracketed IPv6 is rejected separately. +// WHATWG normalizes obfuscated IPv4 (e.g. 2130706433, 0x7f.0.0.1) to dotted-decimal on https, so +// this catches every IPv4 form there; on a non-special scheme like ssh it catches the dotted form +// only. Bracketed IPv6 is rejected separately. const IPV4_HOST_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/; const GITHUB_ORG_REGEX = /^[A-Za-z0-9-]+$/; const GITHUB_REPO_REGEX = /^[A-Za-z0-9._-]+$/; -const SSH_SCP_REGEX = /^([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,}):([a-z0-9._-]+(?:\/[a-z0-9._-]+)+?)(?:\.git)?\/?$/i; -const SSH_URL_REGEX = - /^ssh:\/\/([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,})(:\d+)?\/([a-z0-9._-]+(?:\/[a-z0-9._-]+)+?)(?:\.git)?\/?$/i; -const DOTS_ONLY_SEGMENT_REGEX = /^\.+$/; +const SSH_SCHEME = "ssh://"; +const SSH_SCP_REGEX = /^([a-z0-9._-]+)@([^:/@]+):(?!\/)(.+)$/i; const buildRepoUrl = (url: URL): string => `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`; const pathSegments = (url: URL): string[] => url.pathname.split("/").filter((seg) => seg !== ""); +const toUrl = (candidate: string): URL | null => { + try { + return new URL(candidate); + } catch { + return null; + } +}; + +/** One host rule for every scheme, so an ssh remote is neither more nor less trusted than its https twin. */ +const isSafeHost = (url: URL): boolean => + url.hostname.includes(".") && !url.hostname.startsWith("[") && !IPV4_HOST_REGEX.test(url.hostname); + /** * Validate and normalize a repository URL into a parsed URL, or null. Enforces https (rejects * http/ssh/git/etc.), rejects embedded credentials, and requires a dotted host, so the public @@ -57,20 +68,8 @@ const parseRepoUrl = (raw: string): URL | null => { return null; } const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; - let url: URL; - try { - url = new URL(withScheme); - } catch { - return null; - } - if ( - url.protocol !== "https:" || - url.username !== "" || - url.password !== "" || - !url.hostname.includes(".") || - url.hostname.startsWith("[") || - IPV4_HOST_REGEX.test(url.hostname) - ) { + const url = toUrl(withScheme); + if (!url || url.protocol !== "https:" || url.username !== "" || url.password !== "" || !isSafeHost(url)) { return null; } return url; @@ -178,32 +177,29 @@ const parseRawGitSource = (url: URL, subPath?: string): SkillSourcePreview | nul return buildGitSourcePreview("Git", buildRepoUrl(url), repoName, subPath); }; -interface SshRemote { - cloneUrl: string; - repoName: string; -} - -const buildSshRemote = (rawPath: string, toCloneUrl: (repoPath: string) => string): SshRemote | null => { - if (rawPath.split("/").some((segment) => DOTS_ONLY_SEGMENT_REGEX.test(segment))) { +/** + * Parse an scp-style `git@host:org/repo` or `ssh://git@host/org/repo` clone URL, registering it + * exactly as typed: git treats the `.git` suffix as optional, and forcing one on breaks hosts whose + * paths are not `org/repo`, like Azure DevOps `v3/...` and CodeCommit `v1/repos/...`. The scp form is + * rewritten to `ssh://` only to reuse the https host and credential rules, and only a URL that + * survives that round trip unchanged is accepted, which keeps traversal segments off the feed. + */ +const parseSshSource = (raw: string, subPath?: string): SkillSourcePreview | null => { + const trimmed = raw.trim(); + const scp = SSH_SCP_REGEX.exec(trimmed); + const candidate = scp ? `${SSH_SCHEME}${scp[1]}@${scp[2]}/${scp[3]}` : trimmed; + if (!candidate.toLowerCase().startsWith(SSH_SCHEME)) { return null; } - const bare = rawPath.replace(/\.git$/i, ""); - return { cloneUrl: toCloneUrl(`${bare}.git`), repoName: lastSegment(bare) }; -}; - -const parseSshRemote = (raw: string): SshRemote | null => { - const trimmed = raw.trim(); - const sshUrl = SSH_URL_REGEX.exec(trimmed); - if (sshUrl) { - const [, user, host, port, path] = sshUrl; - return buildSshRemote(path, (repoPath) => `ssh://${user}@${host}${port ?? ""}/${repoPath}`); + const url = toUrl(candidate); + if (!url || url.username === "" || url.password !== "" || !isSafeHost(url)) { + return null; } - const scp = SSH_SCP_REGEX.exec(trimmed); - if (scp) { - const [, user, host, path] = scp; - return buildSshRemote(path, (repoPath) => `${user}@${host}:${repoPath}`); + const pathStart = candidate.indexOf("/", SSH_SCHEME.length); + if (pathStart === -1 || url.pathname !== candidate.slice(pathStart) || pathSegments(url).length < 2) { + return null; } - return null; + return buildGitSourcePreview("SSH", trimmed, lastSegment(url.pathname).replace(/\.git$/i, ""), subPath); }; const parseArchiveSource = (url: URL): SkillSourcePreview => ({ @@ -220,9 +216,9 @@ const parseArchiveSource = (url: URL): SkillSourcePreview => ({ * with an optional subfolder turning it into git-subdir. */ export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourcePreview | null => { - const ssh = parseSshRemote(rawUrl); + const ssh = parseSshSource(rawUrl, subPath); if (ssh) { - return buildGitSourcePreview("SSH", ssh.cloneUrl, ssh.repoName, subPath); + return ssh; } const url = parseRepoUrl(rawUrl); if (!url) { From 7cd6869cfaf7f4c84995a8b878800ac73eac65aa Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 16 Sep 2026 18:40:10 +0000 Subject: [PATCH 6/8] test(ui): match skill source links by exact name so codeql stops flagging the host regexes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/claude_code_plugins/skill_detail.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx index 2f600397a4b..1ec1f72f4a1 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.test.tsx @@ -15,7 +15,7 @@ const buildSkill = (source: Plugin["source"]): Plugin => ({ describe("SkillDetail source", () => { it("links a github source to the repository", () => { render(); - expect(screen.getByRole("link", { name: /github.com\/org\/repo/ })).toHaveAttribute( + expect(screen.getByRole("link", { name: "github.com/org/repo" })).toHaveAttribute( "href", "https://github.com/org/repo", ); @@ -26,7 +26,7 @@ describe("SkillDetail source", () => { , ); expect(screen.getByText("git@ghe.example.com:org/repo.git")).toBeInTheDocument(); - expect(screen.queryByRole("link", { name: /ghe.example.com/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); }); it("renders an ssh git-subdir source as plain text without a tree path", () => { @@ -37,6 +37,6 @@ describe("SkillDetail source", () => { />, ); expect(screen.getByText("git@ghe.example.com:org/repo.git @ plugins/x")).toBeInTheDocument(); - expect(screen.queryByRole("link", { name: /ghe.example.com/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); }); }); From 2bb478fb59b99d3ea46e473bad44f8c2157b3430 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 16 Sep 2026 18:45:11 +0000 Subject: [PATCH 7/8] fix(ui): keep http and upper-case https skill sources clickable on the detail page Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/claude_code_plugins/helpers.test.ts | 9 +++++++++ .../src/components/claude_code_plugins/helpers.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts index 5bf782aabf3..e40e0e0c783 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts @@ -157,6 +157,15 @@ describe("getSourceLink", () => { expect(getSourceLink({ source: "github" })).toBeNull(); }); + it("keeps http and upper-case https urls registered through the api clickable", () => { + expect(getSourceLink({ source: "url", url: "http://git.internal.example/org/repo" })).toBe( + "http://git.internal.example/org/repo", + ); + expect(getSourceLink({ source: "git-subdir", url: "HTTPS://gitlab.com/org/repo", path: "sub/dir" })).toBe( + "HTTPS://gitlab.com/org/repo", + ); + }); + it("returns null for an ssh clone url, which is not browsable", () => { expect(getSourceLink({ source: "url", url: "git@ghe.example.com:org/repo.git" })).toBeNull(); expect(getSourceLink({ source: "url", url: "ssh://git@ghe.example.com/org/repo.git" })).toBeNull(); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts index b4d9eab08ec..8cf620d9077 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -37,6 +37,7 @@ const IPV4_HOST_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/; const GITHUB_ORG_REGEX = /^[A-Za-z0-9-]+$/; const GITHUB_REPO_REGEX = /^[A-Za-z0-9._-]+$/; +const BROWSABLE_URL_REGEX = /^https?:\/\//i; const SSH_SCHEME = "ssh://"; const SSH_SCP_REGEX = /^([a-z0-9._-]+)@([^:/@]+):(?!\/)(.+)$/i; @@ -316,7 +317,7 @@ export const getSourceLink = (source: PluginSource): string | null => { return `https://github.com/${source.repo}`; } const linksToUrl = source.source === "url" || source.source === "git-subdir" || source.source === "archive"; - return linksToUrl && source.url?.startsWith("https://") ? source.url : null; + return linksToUrl && source.url && BROWSABLE_URL_REGEX.test(source.url) ? source.url : null; }; /** From a6b10ad654948fd70d84c697542703cd3b4e2a0f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 16 Sep 2026 14:58:57 -0700 Subject: [PATCH 8/8] fix(prices): dedupe Nova cache_read_input_token_cost keys left by a text merge PR #41343 and PR #41112 both added cache_read_input_token_cost to the six amazon.nova-{micro,lite,pro}-v1:0 and us.amazon.nova-* entries, one at the top of each entry and one at the bottom. The merge kept both, so every PR now fails test_price_map_has_no_duplicate_keys. Both copies carried the same value, so this only removes the trailing duplicate in both price files --- ...model_prices_and_context_window_backup.json | 18 ++++++------------ model_prices_and_context_window.json | 18 ++++++------------ 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b1c38d0350a..92e5b1c4ff7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -377,8 +377,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 1.5e-08 + "supports_tool_choice": true }, "amazon.nova-2-lite-v1:0": { "cache_read_input_token_cost": 7.5e-08, @@ -561,8 +560,7 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 8.75e-09 + "supports_tool_choice": true }, "amazon.nova-pro-v1:0": { "cache_read_input_token_cost": 2e-07, @@ -578,8 +576,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 2e-07 + "supports_tool_choice": true }, "amazon.nova-sonic-v1:0": { "deprecation_date": "2026-09-14", @@ -45794,8 +45791,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 1.5e-08 + "supports_tool_choice": true }, "us.amazon.nova-micro-v1:0": { "cache_read_input_token_cost": 8.75e-09, @@ -45809,8 +45805,7 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 8.75e-09 + "supports_tool_choice": true }, "us.amazon.nova-premier-v1:0": { "deprecation_date": "2026-09-14", @@ -45842,8 +45837,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 2e-07 + "supports_tool_choice": true }, "us.anthropic.claude-3-5-haiku-20241022-v1:0": { "cache_creation_input_token_cost": 1e-06, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b1c38d0350a..92e5b1c4ff7 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -377,8 +377,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 1.5e-08 + "supports_tool_choice": true }, "amazon.nova-2-lite-v1:0": { "cache_read_input_token_cost": 7.5e-08, @@ -561,8 +560,7 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 8.75e-09 + "supports_tool_choice": true }, "amazon.nova-pro-v1:0": { "cache_read_input_token_cost": 2e-07, @@ -578,8 +576,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 2e-07 + "supports_tool_choice": true }, "amazon.nova-sonic-v1:0": { "deprecation_date": "2026-09-14", @@ -45794,8 +45791,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 1.5e-08 + "supports_tool_choice": true }, "us.amazon.nova-micro-v1:0": { "cache_read_input_token_cost": 8.75e-09, @@ -45809,8 +45805,7 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 8.75e-09 + "supports_tool_choice": true }, "us.amazon.nova-premier-v1:0": { "deprecation_date": "2026-09-14", @@ -45842,8 +45837,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true, - "supports_tool_choice": true, - "cache_read_input_token_cost": 2e-07 + "supports_tool_choice": true }, "us.anthropic.claude-3-5-haiku-20241022-v1:0": { "cache_creation_input_token_cost": 1e-06,