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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ flowchart LR

Installing a plugin may download and execute third-party code, including package lifecycle scripts. Review source repositories before installation. Enterprise deployments should place the catalog behind an organizational review and allowlist process.

The native installer validates GitHub repository identifiers, requires the selected catalog entry to be runtime-verified, resolves its exact npm package spec from the Leaderboard detail API, and rejects URLs, git specs, shell syntax, and unverified entries.
The native installer validates GitHub repository identifiers, resolves the selected catalog entry against its detail record, and keeps one-click installation restricted to verified npm package specs. Verified GitHub subdirectory and other non-npm sources are shown as manual-install instructions with copy-only behavior; they are never passed to the installer process. Conflicting or missing verification evidence is surfaced as a blocked or explicitly warned state.

Report security issues through GitHub's
[private vulnerability reporting](https://github.com/sandbaseai/dsh-plugin-store/security/advisories/new),
Expand Down
6 changes: 4 additions & 2 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ Release tarball 安装仓库中已提交的 Host 与 Web client 构建产物。P
- 仅安装到本地 `web` profile
- 仓库必须符合 GitHub `owner/repository` 格式
- 仓库必须先出现在已加载的社区目录中
- 仅允许 Leaderboard 已完成运行时验证的插件
- 安装 spec 必须来自详情 API,且只能是 npm package spec;拒绝 URL、git spec 与 shell 语法
- 一键安装仅允许目录与详情记录均已验证的 npm package spec
- 已验证的 GitHub 子目录和其他非 npm 来源只展示手动安装命令,支持复制但不会自动执行
- 目录与详情验证状态冲突或缺失时会明确阻断或显示警告
- 安装 spec 必须来自详情 API;自动安装拒绝 URL、git spec 与 shell 语法
- 新插件需要重启 DSH 后生效

## 开发
Expand Down
165 changes: 132 additions & 33 deletions lib/client.js

Large diffs are not rendered by default.

204 changes: 176 additions & 28 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,68 @@
import { spawn } from "node:child_process";
import z from "@deepseek-ai/schemastery";
import { defineTool } from "@deepseek-ai/dsh-tools";
//#region lib/types/install-source.js
function getInstallAvailability(catalogVerificationStatus, detailVerificationStatus, sourceKind) {
const catalogVerified = catalogVerificationStatus === "verified";
const detailVerified = detailVerificationStatus === "runtime_verified";
const conflict = catalogVerified !== detailVerified;
if (!catalogVerified && !detailVerified) return "unverified";
if (sourceKind === "npm") return conflict ? "conflict" : "installable";
if (sourceKind === "github-subdir" || sourceKind === "manual") return detailVerified ? "manual" : conflict ? "conflict" : "unsupported";
return conflict ? "conflict" : "unsupported";
}
const COMMAND_RE = /^dsh plugin --profile web add (?:"([^"\r\n]+)"|'([^'\r\n]+)'|(\S+))$/;
const NPM_SPEC_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*(?:@[a-z0-9][a-z0-9._+~-]*)?$/i;
const GITHUB_REPO_RE = /^github:[a-z0-9_.-]+\/[a-z0-9_.-]+$/i;
const GITHUB_REF_RE = /^github:[a-z0-9_.-]+\/[a-z0-9_.-]+#[a-z0-9][a-z0-9._/-]*$/i;
const GITHUB_SUBDIR_RE = /^github:[a-z0-9_.-]+\/[a-z0-9_.-]+#(?:(?:[a-z0-9][a-z0-9._/-]*)&)?path:(\/?[a-z0-9._/-]+)$/i;
function hasUnsafePathSegment(value) {
return value.split("/").some((segment, index) => segment === ".." || segment.length === 0 && index !== 0);
}
function extractSpec(installPath) {
const match = COMMAND_RE.exec(installPath.trim());
return match?.[1] ?? match?.[2] ?? match?.[3];
}
function formatInstallCommand(spec) {
const command = `dsh plugin --profile web add ${spec}`;
return spec.startsWith("github:") ? `dsh plugin --profile web add '${spec}'` : command;
}
function classifyInstallPath(installPath) {
if (typeof installPath !== "string" || installPath.trim().length === 0) return {
kind: "invalid",
reason: "The verified install command is missing"
};
const spec = extractSpec(installPath);
if (spec === void 0) return {
kind: "invalid",
reason: "The verified install command has an unsupported shape"
};
if (NPM_SPEC_RE.test(spec)) return {
kind: "npm",
spec
};
const githubSubdir = GITHUB_SUBDIR_RE.exec(spec);
if (githubSubdir !== null) {
const path = githubSubdir[1];
if (path !== void 0 && !hasUnsafePathSegment(path)) return {
kind: "github-subdir",
spec
};
return {
kind: "unsupported",
reason: "The verified source contains an unsafe path"
};
}
if (GITHUB_REPO_RE.test(spec) || GITHUB_REF_RE.test(spec)) return {
kind: "manual",
spec
};
return {
kind: "unsupported",
reason: "The verified source is not supported by the native installer"
};
}
//#endregion
//#region lib/types/index.js
/**
* DSH Plugin Store — Browse and install plugins from the DSH Plugin Leaderboard.
Expand All @@ -26,21 +88,27 @@ const Config = z.object({
let cachedCatalog = null;
let cacheTime = 0;
const CACHE_TTL = 3e5;
const knownCatalogRepositories = /* @__PURE__ */ new Map();
function normalizeStorePlugin(item) {
const detailPath = item.detailPath ?? item.href;
return detailPath === void 0 ? item : {
...item,
detailPath
};
}
function handleUpstreamError(err, toolName) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`${toolName}: ${message}`);
}
async function getCatalog(catalogUrl, timeoutMs = 3e4) {
async function getCatalog(catalogUrl, timeoutMs = 3e4, forceRefresh = false) {
const now = Date.now();
if (cachedCatalog && now - cacheTime < CACHE_TTL) return cachedCatalog;
if (!forceRefresh && cachedCatalog && now - cacheTime < CACHE_TTL) return cachedCatalog;
const response = await fetch(catalogUrl, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) {
if (cachedCatalog) return cachedCatalog;
if (!forceRefresh && cachedCatalog) return cachedCatalog;
throw new Error(`Catalog fetch failed: HTTP ${response.status}`);
}
const raw = await response.json();
const source = raw.catalog ?? (raw.items ?? []).map((item) => ({
const source = (raw.catalog ?? (raw.items ?? []).map((item) => ({
id: item.repository,
name: item.repository.split("/").at(-1) ?? item.repository,
repository: item.repository,
Expand All @@ -53,12 +121,9 @@ async function getCatalog(catalogUrl, timeoutMs = 3e4) {
overallScore: Math.max(0, 1e3 - (item.rank ?? 1e3)),
stars7dDelta: item.stars7dDelta ?? 0,
createdAt: "",
updatedAt: ""
}));
for (const item of raw.items ?? []) if (item.href?.startsWith("/plugins/") === true) knownCatalogRepositories.set(item.repository, {
href: item.href,
verificationStatus: item.installVerificationStatus ?? ""
});
updatedAt: "",
...item.href === void 0 ? {} : { detailPath: item.href }
}))).map(normalizeStorePlugin);
cachedCatalog = {
catalog: source,
metrics: { pluginsTracked: raw.metrics?.pluginsTracked ?? source.length },
Expand All @@ -75,7 +140,7 @@ async function getCatalogPage(catalogUrl, limit, offset, category = "", timeoutM
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) throw new Error(`Catalog fetch failed: HTTP ${response.status}`);
const raw = await response.json();
const items = raw.catalog ?? (raw.items ?? []).map((item) => ({
const items = (raw.catalog ?? (raw.items ?? []).map((item) => ({
id: item.repository,
name: item.repository.split("/").at(-1) ?? item.repository,
repository: item.repository,
Expand All @@ -88,12 +153,9 @@ async function getCatalogPage(catalogUrl, limit, offset, category = "", timeoutM
overallScore: Math.max(0, 1e3 - (item.rank ?? 1e3)),
stars7dDelta: item.stars7dDelta ?? 0,
createdAt: "",
updatedAt: ""
}));
for (const item of raw.items ?? []) if (item.href?.startsWith("/plugins/") === true) knownCatalogRepositories.set(item.repository, {
href: item.href,
verificationStatus: item.installVerificationStatus ?? ""
});
updatedAt: "",
...item.href === void 0 ? {} : { detailPath: item.href }
}))).map(normalizeStorePlugin);
return {
items,
total: raw.total ?? raw.metrics?.pluginsTracked ?? items.length,
Expand All @@ -120,20 +182,56 @@ async function readBody(req) {
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
async function resolveInstallSpec(repository, catalogUrl, timeoutMs) {
const catalogEntry = knownCatalogRepositories.get(repository);
if (catalogEntry === void 0) throw new Error("Load this repository from the community catalog before installing it");
if (catalogEntry.verificationStatus !== "verified") throw new Error("This plugin has not passed the leaderboard runtime installation check");
async function resolveCatalogReference(repository, detailPath, catalogUrl, timeoutMs) {
if (detailPath !== void 0 && !/^\/plugins\/[a-z0-9-]+$/i.test(detailPath)) throw new Error("The selected plugin has an invalid catalog detail path");
const pageSize = 100;
let offset = 0;
let total = Number.POSITIVE_INFINITY;
while (offset < total) {
const page = await getCatalogPage(catalogUrl, pageSize, offset, "", timeoutMs);
const selected = page.items.filter((item) => item.repository === repository && (detailPath === void 0 || item.detailPath === detailPath)).find((item) => item.detailPath !== void 0);
if (selected?.detailPath !== void 0) return {
href: selected.detailPath,
repository: selected.repository,
verificationStatus: selected.verificationStatus
};
if (page.items.length === 0) break;
offset += page.items.length;
total = page.total;
}
throw new Error("Load this repository from the current community catalog before installing it");
}
async function resolveInstallSource(repository, detailPath, catalogUrl, timeoutMs) {
const catalogEntry = await resolveCatalogReference(repository, detailPath, catalogUrl, timeoutMs);
if (!/^\/plugins\/[a-z0-9-]+$/i.test(catalogEntry.href)) throw new Error("The catalog returned an invalid plugin detail path");
const detailUrl = new URL(`/api${catalogEntry.href}`, new URL(catalogUrl).origin);
const response = await fetch(detailUrl, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) throw new Error(`Plugin detail fetch failed: HTTP ${response.status}`);
const plugin = (await response.json()).plugin;
if (plugin?.repository !== repository) throw new Error("Plugin detail does not match the selected repository");
if (plugin.verificationStatus !== "runtime_verified") throw new Error("This plugin is not runtime-verified");
const spec = /^dsh plugin --profile web add ([^\s]+)$/.exec(plugin.installPath ?? "")?.[1];
if (spec === void 0 || !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[a-z0-9][a-z0-9._+-]*)?$/i.test(spec)) throw new Error("The verified install command is not a supported npm package spec");
return spec;
const detailVerificationStatus = plugin?.verificationStatus ?? "unknown";
const source = classifyInstallPath(plugin?.installPath);
const catalogVerified = catalogEntry.verificationStatus === "verified";
const detailVerified = detailVerificationStatus === "runtime_verified";
const availability = getInstallAvailability(catalogEntry.verificationStatus, detailVerificationStatus, source.kind);
return {
repository,
detailPath: catalogEntry.href,
catalogVerificationStatus: catalogEntry.verificationStatus,
detailVerificationStatus,
verificationConflict: catalogVerified !== detailVerified,
availability,
source,
...source.spec === void 0 ? {} : { installPath: formatInstallCommand(source.spec) },
...(plugin?.prerequisites ?? plugin?.runtimePrerequisites) === void 0 ? {} : { prerequisites: plugin.prerequisites ?? plugin.runtimePrerequisites }
};
}
async function resolveInstallSpec(repository, detailPath, catalogUrl, timeoutMs) {
const source = await resolveInstallSource(repository, detailPath, catalogUrl, timeoutMs);
if (source.availability === "conflict") throw new Error("Catalog and detail verification results conflict");
if (source.availability === "unverified") throw new Error("This plugin has not passed the leaderboard runtime installation check");
if (source.availability !== "installable" || source.source.spec === void 0) throw new Error("The verified install command is not a supported npm package spec");
return source.source.spec;
}
async function installPackage(spec) {
const entry = process.argv[1];
Expand Down Expand Up @@ -199,6 +297,7 @@ function apply(ctx, config = {}) {
categories: page.categories,
items: page.items.map((plugin) => ({
repository: plugin.repository,
detailPath: plugin.detailPath,
name: plugin.name,
description: plugin.description,
categories: plugin.categories,
Expand All @@ -221,8 +320,9 @@ function apply(ctx, config = {}) {
try {
const body = await readBody(req);
const repository = typeof body.repository === "string" ? body.repository : "";
const detailPath = typeof body.detailPath === "string" ? body.detailPath : void 0;
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error("Invalid GitHub repository");
const spec = await resolveInstallSpec(repository, catalogUrl, timeoutMs);
const spec = await resolveInstallSpec(repository, detailPath, catalogUrl, timeoutMs);
await installPackage(spec);
sendJson(res, 200, {
ok: true,
Expand All @@ -233,6 +333,22 @@ function apply(ctx, config = {}) {
}
}
}), "dsh-store: install route");
ctx.effect(() => ctx.webServer.register({
kind: "exact",
path: "/api/plugin-store/source",
handler: async (req, res) => {
if (req.method !== "POST") return sendJson(res, 405, { error: "Method not allowed" });
try {
const body = await readBody(req);
const repository = typeof body.repository === "string" ? body.repository : "";
const detailPath = typeof body.detailPath === "string" ? body.detailPath : void 0;
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error("Invalid GitHub repository");
sendJson(res, 200, await resolveInstallSource(repository, detailPath, catalogUrl, timeoutMs));
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
}
}
}), "dsh-store: source route");
(async () => {
ctx.tools.register(defineTool({
name: "store_search",
Expand Down Expand Up @@ -370,7 +486,39 @@ function apply(ctx, config = {}) {
const q = String(args.name).toLowerCase();
const plugin = catalog.catalog.find((p) => p.name.toLowerCase() === q || p.repository.toLowerCase().includes(q) || p.id.toLowerCase().includes(q));
if (!plugin) return `Plugin "${args.name}" not found in the store. Try store_search to find plugins.`;
const installCmd = `dsh plugin --profile web add -w ${await resolveInstallSpec(plugin.repository, catalogUrl, timeoutMs)}`;
const source = await resolveInstallSource(plugin.repository, plugin.detailPath, catalogUrl, timeoutMs);
if (source.availability === "conflict") return [
`# ${plugin.name}`,
`**Status**: verification conflict`,
"",
"The catalog and detail records disagree. Do not install this plugin until the evidence is reconciled."
].join("\n");
if (source.availability === "unverified") return [
`# ${plugin.name}`,
`**Status**: unverified`,
"",
"This plugin has not passed the required runtime verification."
].join("\n");
if (source.availability !== "installable" || source.source.spec === void 0) {
const prerequisites = source.prerequisites?.length ? `\n**Prerequisites**: ${source.prerequisites.join(", ")}` : "";
const manual = source.availability === "manual" || source.source.kind === "github-subdir";
return [
`# ${plugin.name}`,
`**Description**: ${plugin.description}`,
`**Repository**: https://github.com/${plugin.repository}`,
`**Status**: ${manual ? "manual install" : "unsupported source"}`,
source.verificationConflict ? `**Verification evidence**: catalog=${source.catalogVerificationStatus}, detail=${source.detailVerificationStatus} (conflict; manual execution only)` : "",
prerequisites,
"",
manual ? `## Manual install` : `## Installation blocked`,
"```bash",
source.installPath ?? source.source.reason ?? "No verified install command is available.",
"```",
"",
manual ? "Review the source and prerequisites, then run the command yourself. The Store will not execute non-npm sources." : "The Store will not execute this source because it is outside the supported install boundary."
].join("\n");
}
const installCmd = `dsh plugin --profile web add -w ${source.source.spec}`;
return [
`# ${plugin.name}`,
`**Description**: ${plugin.description}`,
Expand Down
12 changes: 12 additions & 0 deletions lib/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
import type { Context } from '@deepseek-ai/cordis';
import z from '@deepseek-ai/schemastery';
import { type InstallSourceClassification } from './install-source';
export declare const name = "dsh_plugin_store";
export declare const inject: readonly ["tools", "web", "webServer"];
export interface Config {
Expand All @@ -18,5 +19,16 @@ export interface Config {
timeoutMs?: number;
}
export declare const Config: z<Config>;
export interface InstallSourceInfo {
repository: string;
detailPath: string;
catalogVerificationStatus: string;
detailVerificationStatus: string;
verificationConflict: boolean;
availability: 'installable' | 'manual' | 'unverified' | 'conflict' | 'unsupported';
source: InstallSourceClassification;
installPath?: string;
prerequisites?: string[];
}
export declare function apply(ctx: Context, config?: Config): void;
//# sourceMappingURL=index.d.ts.map
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"scripts": {
"bundle": "tsdown --env.DSH_BUILD_FACE client",
"typecheck": "tsc -b --pretty false",
"test:source": "node --experimental-strip-types --test tests/install-source.test.mjs",
"watch": "tsdown --watch"
},
"files": ["lib/index.js", "lib/client.js", "lib/types/**/*.d.ts", "cordis.patch.yml", "README.md", "README.zh.md"],
Expand Down
Loading