vskill 1.0.14 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/agents.json +1 -1
- package/dist/bin.js +0 -0
- package/dist/clone/github-scaffold.d.ts +38 -0
- package/dist/clone/github-scaffold.js +108 -0
- package/dist/clone/github-scaffold.js.map +1 -0
- package/dist/clone/provenance-fork.d.ts +34 -0
- package/dist/clone/provenance-fork.js +97 -0
- package/dist/clone/provenance-fork.js.map +1 -0
- package/dist/clone/reference-scanner.d.ts +19 -0
- package/dist/clone/reference-scanner.js +144 -0
- package/dist/clone/reference-scanner.js.map +1 -0
- package/dist/clone/skill-locator.d.ts +26 -0
- package/dist/clone/skill-locator.js +248 -0
- package/dist/clone/skill-locator.js.map +1 -0
- package/dist/clone/target-router.d.ts +73 -0
- package/dist/clone/target-router.js +200 -0
- package/dist/clone/target-router.js.map +1 -0
- package/dist/clone/types.d.ts +82 -0
- package/dist/clone/types.js +11 -0
- package/dist/clone/types.js.map +1 -0
- package/dist/commands/add.js +96 -32
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/auth.d.ts +23 -0
- package/dist/commands/auth.js +273 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/clone-prompts.d.ts +13 -0
- package/dist/commands/clone-prompts.js +67 -0
- package/dist/commands/clone-prompts.js.map +1 -0
- package/dist/commands/clone.d.ts +70 -0
- package/dist/commands/clone.js +649 -0
- package/dist/commands/clone.js.map +1 -0
- package/dist/commands/eval/serve.js +8 -1
- package/dist/commands/eval/serve.js.map +1 -1
- package/dist/commands/keys.js +54 -2
- package/dist/commands/keys.js.map +1 -1
- package/dist/eval/skill-scanner.d.ts +2 -12
- package/dist/eval/skill-scanner.js +27 -5
- package/dist/eval/skill-scanner.js.map +1 -1
- package/dist/eval-server/api-routes.js +338 -31
- package/dist/eval-server/api-routes.js.map +1 -1
- package/dist/eval-server/data-events.d.ts +1 -1
- package/dist/eval-server/data-events.js.map +1 -1
- package/dist/eval-server/install-engine-routes-helpers.d.ts +1 -3
- package/dist/eval-server/install-engine-routes-helpers.js +6 -14
- package/dist/eval-server/install-engine-routes-helpers.js.map +1 -1
- package/dist/eval-server/origin-resolver.d.ts +42 -0
- package/dist/eval-server/origin-resolver.js +168 -0
- package/dist/eval-server/origin-resolver.js.map +1 -0
- package/dist/eval-server/platform-proxy.d.ts +10 -0
- package/dist/eval-server/platform-proxy.js +58 -2
- package/dist/eval-server/platform-proxy.js.map +1 -1
- package/dist/eval-server/skill-resolver.js +40 -0
- package/dist/eval-server/skill-resolver.js.map +1 -1
- package/dist/eval-server/utils/resolve-editor.d.ts +6 -1
- package/dist/eval-server/utils/resolve-editor.js +11 -26
- package/dist/eval-server/utils/resolve-editor.js.map +1 -1
- package/dist/eval-server/utils/scan-install-locations.d.ts +7 -0
- package/dist/eval-server/utils/scan-install-locations.js +20 -0
- package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
- package/dist/eval-server/utils/which.d.ts +15 -0
- package/dist/eval-server/utils/which.js +76 -0
- package/dist/eval-server/utils/which.js.map +1 -0
- package/dist/eval-ui/assets/{CreateSkillPage-CKvqAya0.js → CreateSkillPage-BmbvQEzE.js} +1 -1
- package/dist/eval-ui/assets/{FindSkillsPalette-B8pTa5NP.js → FindSkillsPalette-D0Zjhm31.js} +2 -2
- package/dist/eval-ui/assets/{SearchPaletteCore-CkVRvaZk.js → SearchPaletteCore-EhcN1xEa.js} +1 -1
- package/dist/eval-ui/assets/SkillDetailPanel-B5J60ffv.js +1 -0
- package/dist/eval-ui/assets/{UpdateDropdown-DA7OktXO.js → UpdateDropdown-Celf0_Cr.js} +1 -1
- package/dist/eval-ui/assets/{index-DCbohW6l.js → index-BV7k6fdk.js} +43 -41
- package/dist/eval-ui/assets/{index-BKAvJDDF.css → index-CKLqBL52.css} +1 -1
- package/dist/eval-ui/index.html +2 -2
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/frontmatter.d.ts +26 -0
- package/dist/installer/frontmatter.js +90 -0
- package/dist/installer/frontmatter.js.map +1 -1
- package/dist/lib/github-fetch.d.ts +22 -0
- package/dist/lib/github-fetch.js +152 -0
- package/dist/lib/github-fetch.js.map +1 -0
- package/dist/lib/keychain.d.ts +41 -0
- package/dist/lib/keychain.js +232 -0
- package/dist/lib/keychain.js.map +1 -0
- package/dist/studio/types.d.ts +13 -0
- package/dist/utils/claude-plugin.d.ts +26 -0
- package/dist/utils/claude-plugin.js +60 -0
- package/dist/utils/claude-plugin.js.map +1 -1
- package/package.json +2 -1
- package/dist/eval-ui/assets/SkillDetailPanel-d4_LquVH.js +0 -1
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// api-routes.ts -- REST API route handlers for the eval UI
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
5
|
-
import {
|
|
6
|
-
import { join, resolve, dirname } from "node:path";
|
|
5
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
6
|
+
import { join, resolve, dirname, basename } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { sendJson, readBody } from "./router.js";
|
|
9
9
|
import { initSSE, sendSSE, sendSSEDone, withHeartbeat, startDynamicHeartbeat } from "./sse-helpers.js";
|
|
@@ -11,9 +11,13 @@ import { dataEventBus, emitDataEvent } from "./data-events.js";
|
|
|
11
11
|
import { classifyError } from "./error-classifier.js";
|
|
12
12
|
import { readLockfile, writeLockfile } from "../lockfile/lockfile.js";
|
|
13
13
|
import { resolveSkillApiName as resolveSkillApiNameImpl } from "./skill-name-resolver.js";
|
|
14
|
+
import { resolveSkillOrigin } from "./origin-resolver.js";
|
|
14
15
|
import { runBenchmarkSSE, runSingleCaseSSE } from "./benchmark-runner.js";
|
|
15
16
|
import { getSkillSemaphore } from "./concurrency.js";
|
|
16
17
|
import { resolveSkillDir, resolveAllowedSkillDir } from "./skill-resolver.js";
|
|
18
|
+
import { scanSkillInstallLocations, pickHighestPrecedenceLocation } from "./utils/scan-install-locations.js";
|
|
19
|
+
import { whichSync } from "./utils/which.js";
|
|
20
|
+
import { resolveEditorCommand, NoEditorError } from "./utils/resolve-editor.js";
|
|
17
21
|
import { setSkillDirEntry, ensurePluginCacheEntry } from "./skill-dir-registry.js";
|
|
18
22
|
import { pickInstalledVersion, readSkillMd, sha256Hex, } from "./installed-version.js";
|
|
19
23
|
import { extractFrontmatterVersion } from "../utils/version.js";
|
|
@@ -1068,14 +1072,9 @@ export function detectProjectAgents(root) {
|
|
|
1068
1072
|
return data;
|
|
1069
1073
|
}
|
|
1070
1074
|
function isBinaryOnPath(name) {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
return true;
|
|
1075
|
-
}
|
|
1076
|
-
catch {
|
|
1077
|
-
return false;
|
|
1078
|
-
}
|
|
1075
|
+
// 0820 follow-up: delegate to the shared utils/which.ts helper. Cached
|
|
1076
|
+
// there per-process, so the outer `detectionCache` is now belt-and-braces.
|
|
1077
|
+
return whichSync(name);
|
|
1079
1078
|
}
|
|
1080
1079
|
// 0701 — Read the active Claude Code model from ~/.claude/settings.json so the
|
|
1081
1080
|
// Studio picker can surface "routing to claude-opus-4-7[1m]" under the generic
|
|
@@ -1681,9 +1680,7 @@ export function registerRoutes(router, root, projectName) {
|
|
|
1681
1680
|
const localSkill = r.name.split("/").pop();
|
|
1682
1681
|
// Highest-precedence install wins for the local fs pair the click
|
|
1683
1682
|
// handler reveals: project > personal > plugin.
|
|
1684
|
-
const
|
|
1685
|
-
const winner = [...locations].sort((a, b) => precedence[a.scope] -
|
|
1686
|
-
precedence[b.scope])[0];
|
|
1683
|
+
const winner = pickHighestPrecedenceLocation(locations);
|
|
1687
1684
|
return {
|
|
1688
1685
|
...r,
|
|
1689
1686
|
...(programmatic.pinMap.has(r.name)
|
|
@@ -1724,17 +1721,41 @@ export function registerRoutes(router, root, projectName) {
|
|
|
1724
1721
|
// "no version history" without treating it as an error.
|
|
1725
1722
|
//
|
|
1726
1723
|
// Never returns 5xx for the "no VCS surface" case — that is normal empty state.
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1724
|
+
// 0823 F-001: shared apiPath builder used by /versions, /versions/diff,
|
|
1725
|
+
// and the rescan endpoint so all three resolve via the same origin chain
|
|
1726
|
+
// (project lockfile → ~/.agents → Anthropic registry → git-remote fallback).
|
|
1727
|
+
// Returns `{ apiPathRoot, origin }` — callers append `/versions` or
|
|
1728
|
+
// `/versions/diff` as needed.
|
|
1729
|
+
async function buildSkillApiPath(skill, plugin) {
|
|
1730
|
+
const origin = await resolveSkillOrigin(skill, plugin, root);
|
|
1731
|
+
if (origin.owner && origin.repo) {
|
|
1732
|
+
return {
|
|
1733
|
+
apiPathRoot: `/api/v1/skills/${encodeURIComponent(origin.owner)}/${encodeURIComponent(origin.repo)}/${encodeURIComponent(skill)}`,
|
|
1734
|
+
origin,
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
// 0765 fallback: legacy resolver covers source-tree authored skills
|
|
1738
|
+
// whose origin comes from a git remote, not a lockfile.
|
|
1739
|
+
const fullName = await resolveSkillApiName(skill, plugin);
|
|
1731
1740
|
const parts = fullName.split("/");
|
|
1732
|
-
const
|
|
1733
|
-
? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}
|
|
1734
|
-
: `/api/v1/skills/${encodeURIComponent(fullName)}
|
|
1741
|
+
const apiPathRoot = parts.length === 3
|
|
1742
|
+
? `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}`
|
|
1743
|
+
: `/api/v1/skills/${encodeURIComponent(fullName)}`;
|
|
1744
|
+
return { apiPathRoot, origin };
|
|
1745
|
+
}
|
|
1746
|
+
router.get("/api/skills/:plugin/:skill/versions", async (req, res, params) => {
|
|
1747
|
+
// 0823: comprehensive origin resolver via shared buildSkillApiPath.
|
|
1748
|
+
const { apiPathRoot, origin } = await buildSkillApiPath(params.skill, params.plugin);
|
|
1749
|
+
const apiPath = `${apiPathRoot}/versions`;
|
|
1735
1750
|
const emptyEnvelope = () => {
|
|
1736
1751
|
res.setHeader("X-Skill-VCS", "unavailable");
|
|
1737
|
-
sendJson(res, {
|
|
1752
|
+
sendJson(res, {
|
|
1753
|
+
versions: [],
|
|
1754
|
+
count: 0,
|
|
1755
|
+
source: "none",
|
|
1756
|
+
provider: origin.provider,
|
|
1757
|
+
trackedForUpdates: false,
|
|
1758
|
+
}, 200, req);
|
|
1738
1759
|
};
|
|
1739
1760
|
let fetchResp;
|
|
1740
1761
|
try {
|
|
@@ -1794,7 +1815,23 @@ export function registerRoutes(router, root, projectName) {
|
|
|
1794
1815
|
...v,
|
|
1795
1816
|
isInstalled: installedVersion ? v.version === installedVersion : false,
|
|
1796
1817
|
}));
|
|
1797
|
-
|
|
1818
|
+
// 0823 F-003 (iter 4): runtime-verify Anthropic-registry hits via
|
|
1819
|
+
// trackedForUpdates. If a registry-mapped skill returns no upstream
|
|
1820
|
+
// versions (404 or empty), downgrade the provider chip to "local" so the
|
|
1821
|
+
// UI doesn't claim "Anthropic" for a skill that isn't actually indexed
|
|
1822
|
+
// upstream. Lockfile-derived providers stay regardless of count (the
|
|
1823
|
+
// lockfile entry IS the proof the user installed it from somewhere real).
|
|
1824
|
+
const tracked = origin.trackedForUpdates && enriched.length > 0;
|
|
1825
|
+
const reportedProvider = origin.source === "anthropic-registry" && !tracked ? "local" : origin.provider;
|
|
1826
|
+
sendJson(res, {
|
|
1827
|
+
versions: enriched,
|
|
1828
|
+
count: enriched.length,
|
|
1829
|
+
source: "platform",
|
|
1830
|
+
// 0823: provider classification + tracking flag drive UI chip
|
|
1831
|
+
// and CheckNowButton visibility.
|
|
1832
|
+
provider: reportedProvider,
|
|
1833
|
+
trackedForUpdates: tracked,
|
|
1834
|
+
}, 200, req);
|
|
1798
1835
|
});
|
|
1799
1836
|
// T-010: Diff proxy route
|
|
1800
1837
|
router.get("/api/skills/:plugin/:skill/versions/diff", async (req, res, params) => {
|
|
@@ -1805,12 +1842,11 @@ export function registerRoutes(router, root, projectName) {
|
|
|
1805
1842
|
sendJson(res, { error: "Missing required query params: from and to" }, 400, req);
|
|
1806
1843
|
return;
|
|
1807
1844
|
}
|
|
1808
|
-
//
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
: `/api/v1/skills/${encodeURIComponent(fullName)}/versions/diff`;
|
|
1845
|
+
// 0823 F-001: use the same origin-resolver-aware path builder as /versions
|
|
1846
|
+
// so Anthropic-shipped skills (slack-messaging, pptx, etc.) get the
|
|
1847
|
+
// matching upstream URL for diff requests, not the legacy bare-name path.
|
|
1848
|
+
const { apiPathRoot } = await buildSkillApiPath(params.skill, params.plugin);
|
|
1849
|
+
const basePath = `${apiPathRoot}/versions/diff`;
|
|
1814
1850
|
try {
|
|
1815
1851
|
const resp = await fetch(`${PLATFORM_BASE}${basePath}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { signal: AbortSignal.timeout(10_000) });
|
|
1816
1852
|
// 0746: pass through upstream status + body. Legitimate 4xx from the
|
|
@@ -1855,6 +1891,106 @@ export function registerRoutes(router, root, projectName) {
|
|
|
1855
1891
|
s.length <= 200 &&
|
|
1856
1892
|
!s.startsWith("-") &&
|
|
1857
1893
|
SKILL_SLUG_RE.test(s);
|
|
1894
|
+
// 0823 — POST /api/v1/skills/:id/rescan — single-skill upstream version
|
|
1895
|
+
// check. The client (CheckNowButton) calls this to verify whether a tracked
|
|
1896
|
+
// installed skill has new versions available. The :id param is URL-encoded
|
|
1897
|
+
// `<plugin>/<skill>` (e.g. `.claude%2Fnanobanana`).
|
|
1898
|
+
//
|
|
1899
|
+
// Flow:
|
|
1900
|
+
// 1. URL-decode :id → (plugin, skill); validate slug.
|
|
1901
|
+
// 2. resolveSkillOrigin → upstream owner/repo (or local fallback).
|
|
1902
|
+
// 3. Fetch upstream versions; emit `skill.updated` via dataEventBus so
|
|
1903
|
+
// any local listeners (and the SSE stream once bridged) can react.
|
|
1904
|
+
// 4. Return { jobId } synchronously. The actual update install (if any)
|
|
1905
|
+
// goes through the existing `/update` route — rescan is read-only.
|
|
1906
|
+
//
|
|
1907
|
+
// Idempotent + side-effect free on disk.
|
|
1908
|
+
router.post("/api/v1/skills/:id/rescan", async (req, res, params) => {
|
|
1909
|
+
const rawId = String(params.id ?? "");
|
|
1910
|
+
const decoded = (() => {
|
|
1911
|
+
try {
|
|
1912
|
+
return decodeURIComponent(rawId);
|
|
1913
|
+
}
|
|
1914
|
+
catch {
|
|
1915
|
+
return rawId;
|
|
1916
|
+
}
|
|
1917
|
+
})();
|
|
1918
|
+
const slashIdx = decoded.indexOf("/");
|
|
1919
|
+
if (slashIdx <= 0 || slashIdx === decoded.length - 1) {
|
|
1920
|
+
sendJson(res, { error: `Invalid skill id: ${rawId}` }, 400, req);
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
const plugin = decoded.slice(0, slashIdx);
|
|
1924
|
+
const skill = decoded.slice(slashIdx + 1);
|
|
1925
|
+
if (!isSafeSkillName(skill) || !isSafeSkillName(plugin)) {
|
|
1926
|
+
sendJson(res, { error: `Invalid skill id: ${rawId}` }, 400, req);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
// 0823 F-002 (security iter 4): isSafeSkillName regex permits `/` (legal
|
|
1930
|
+
// for owner/repo/skill triples) so a doubly-encoded `.claude%2F.claude%2Fx`
|
|
1931
|
+
// would split to plugin=.claude, skill=.claude/x. The skill segment after
|
|
1932
|
+
// the first split MUST be a single slug — reject any embedded slash.
|
|
1933
|
+
if (plugin.includes("/") || skill.includes("/")) {
|
|
1934
|
+
sendJson(res, { error: `Invalid skill id (nested slash): ${rawId}` }, 400, req);
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
// 0823 F-005 (low): use crypto.randomUUID per FR-005 spec — collision-
|
|
1938
|
+
// free + auditable. Falls back to a Math.random suffix if the runtime
|
|
1939
|
+
// is too old to expose the global API (Node 14 / very old browsers).
|
|
1940
|
+
const jobId = (() => {
|
|
1941
|
+
try {
|
|
1942
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1943
|
+
const c = globalThis.crypto;
|
|
1944
|
+
if (c?.randomUUID)
|
|
1945
|
+
return `rescan-${c.randomUUID()}`;
|
|
1946
|
+
}
|
|
1947
|
+
catch { /* fall through */ }
|
|
1948
|
+
return `rescan-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1949
|
+
})();
|
|
1950
|
+
let versions = [];
|
|
1951
|
+
try {
|
|
1952
|
+
const origin = await resolveSkillOrigin(skill, plugin, root);
|
|
1953
|
+
if (origin.owner && origin.repo) {
|
|
1954
|
+
const apiPath = `/api/v1/skills/${encodeURIComponent(origin.owner)}/${encodeURIComponent(origin.repo)}/${encodeURIComponent(skill)}/versions`;
|
|
1955
|
+
try {
|
|
1956
|
+
const fetchResp = await fetch(`${PLATFORM_BASE}${apiPath}`, {
|
|
1957
|
+
signal: AbortSignal.timeout(10_000),
|
|
1958
|
+
});
|
|
1959
|
+
if (fetchResp.ok) {
|
|
1960
|
+
const data = (await fetchResp.json());
|
|
1961
|
+
versions = Array.isArray(data.versions) ? data.versions : [];
|
|
1962
|
+
}
|
|
1963
|
+
else {
|
|
1964
|
+
// 0823 F-009: surface non-OK responses for ops debugging — the
|
|
1965
|
+
// user-facing button still treats the rescan as "no change", but
|
|
1966
|
+
// the operator can see why upstream said no in the studio log.
|
|
1967
|
+
console.warn(`[rescan] upstream ${PLATFORM_BASE}${apiPath} → ${fetchResp.status} ${fetchResp.statusText}`);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
catch (fetchErr) {
|
|
1971
|
+
// 0823 F-009: log network failures instead of silently swallowing.
|
|
1972
|
+
console.warn(`[rescan] upstream fetch failed for ${plugin}/${skill}:`, fetchErr.message);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
catch (resolveErr) {
|
|
1977
|
+
// 0823 F-009: resolveSkillOrigin should never throw, but if it does the
|
|
1978
|
+
// operator needs to know — silent failure here would look like "no
|
|
1979
|
+
// upstream changes" to the user forever.
|
|
1980
|
+
console.error(`[rescan] resolveSkillOrigin threw for ${plugin}/${skill}:`, resolveErr.message);
|
|
1981
|
+
}
|
|
1982
|
+
// Emit local event so any in-process listeners (and the SSE bridge once
|
|
1983
|
+
// wired) can push to clients. CheckNowButton's existing 30s timeout
|
|
1984
|
+
// serves as the fallback if no SSE event reaches `updatesById`.
|
|
1985
|
+
emitDataEvent("skill.updated", {
|
|
1986
|
+
plugin,
|
|
1987
|
+
skill,
|
|
1988
|
+
versions,
|
|
1989
|
+
jobId,
|
|
1990
|
+
timestamp: new Date().toISOString(),
|
|
1991
|
+
});
|
|
1992
|
+
sendJson(res, { jobId }, 200, req);
|
|
1993
|
+
});
|
|
1858
1994
|
router.post("/api/skills/:plugin/:skill/update", async (req, res, params) => {
|
|
1859
1995
|
initSSE(res, req);
|
|
1860
1996
|
const skillName = params.skill;
|
|
@@ -1988,6 +2124,18 @@ export function registerRoutes(router, root, projectName) {
|
|
|
1988
2124
|
// file-read routes need to reach into ~/.claude/plugins/{cache,marketplaces}
|
|
1989
2125
|
// and ~/.claude/skills. Path traversal is rejected by validateAgainstAllowlist
|
|
1990
2126
|
// inside resolveAllowedSkillDir.
|
|
2127
|
+
// 0823 simplify: shared trailing-separator-aware containment check used by
|
|
2128
|
+
// /file (read) and /file (PUT/save). Plain `startsWith()` is unsafe because
|
|
2129
|
+
// two skills with shared prefixes (e.g. `foo` + `foo-secret`) collide. This
|
|
2130
|
+
// helper appends the separator and explicitly accepts the exact-match case.
|
|
2131
|
+
// Returns true when `candidate` is at or below `root` after normalization.
|
|
2132
|
+
const isContainedIn = (candidate, root) => {
|
|
2133
|
+
const r = resolve(root);
|
|
2134
|
+
if (candidate === r)
|
|
2135
|
+
return true;
|
|
2136
|
+
const rWithSep = r.endsWith("/") ? r : r + "/";
|
|
2137
|
+
return candidate.startsWith(rWithSep);
|
|
2138
|
+
};
|
|
1991
2139
|
const skillFsAllowedRoots = () => {
|
|
1992
2140
|
const home = homedir();
|
|
1993
2141
|
return [
|
|
@@ -1995,6 +2143,10 @@ export function registerRoutes(router, root, projectName) {
|
|
|
1995
2143
|
join(home, ".claude/plugins/cache"),
|
|
1996
2144
|
join(home, ".claude/plugins/marketplaces"),
|
|
1997
2145
|
join(home, ".claude/skills"),
|
|
2146
|
+
// 0823: vskill install --global writes to ~/.agents/skills, and many
|
|
2147
|
+
// ~/.claude/skills/* entries are symlinks INTO that dir. Without this
|
|
2148
|
+
// allowlist entry the realpath check rejects them as "outside allowlist".
|
|
2149
|
+
join(home, ".agents/skills"),
|
|
1998
2150
|
];
|
|
1999
2151
|
};
|
|
2000
2152
|
// 0769 F-002: cold-server deep links to /api/skills/:plugin/:skill/files (or
|
|
@@ -2097,12 +2249,15 @@ export function registerRoutes(router, root, projectName) {
|
|
|
2097
2249
|
}
|
|
2098
2250
|
const url = new URL(req.url ?? "", "http://localhost");
|
|
2099
2251
|
const filePath = url.searchParams.get("path") ?? "";
|
|
2252
|
+
const raw = url.searchParams.get("raw") === "1";
|
|
2100
2253
|
if (!filePath) {
|
|
2101
2254
|
sendJson(res, { error: "Missing path query parameter" }, 400, req);
|
|
2102
2255
|
return;
|
|
2103
2256
|
}
|
|
2104
2257
|
const fullPath = resolve(join(skillDir, filePath));
|
|
2105
|
-
|
|
2258
|
+
// 0823 F-002 (security): trailing-separator-aware containment via shared
|
|
2259
|
+
// helper — prevents prefix-confusion (e.g. `foo` vs `foo-secret`).
|
|
2260
|
+
if (!isContainedIn(fullPath, skillDir)) {
|
|
2106
2261
|
sendJson(res, { error: "Access denied" }, 403, req);
|
|
2107
2262
|
return;
|
|
2108
2263
|
}
|
|
@@ -2121,7 +2276,6 @@ export function registerRoutes(router, root, projectName) {
|
|
|
2121
2276
|
sendJson(res, { error: "File too large", path: filePath, size }, 413, req);
|
|
2122
2277
|
return;
|
|
2123
2278
|
}
|
|
2124
|
-
// Binary detection: check first 8KB for null bytes
|
|
2125
2279
|
let buf;
|
|
2126
2280
|
try {
|
|
2127
2281
|
buf = readFileSync(fullPath);
|
|
@@ -2130,6 +2284,51 @@ export function registerRoutes(router, root, projectName) {
|
|
|
2130
2284
|
sendJson(res, { error: `Unable to read file: ${err.message}` }, 500, req);
|
|
2131
2285
|
return;
|
|
2132
2286
|
}
|
|
2287
|
+
// 0823 F-004: raw=1 returns the file bytes with a sniffed Content-Type so
|
|
2288
|
+
// <img src=...&raw=1> works for inline preview. Binary detection still
|
|
2289
|
+
// runs; raw mode only changes how the bytes are delivered, not which
|
|
2290
|
+
// files are accepted.
|
|
2291
|
+
if (raw) {
|
|
2292
|
+
const ext = filePath.toLowerCase().slice(filePath.lastIndexOf(".") + 1);
|
|
2293
|
+
// 0823 F-002 (security): SVG is REFUSED via raw because it can carry
|
|
2294
|
+
// <script> that runs in the studio origin (localhost full-API access)
|
|
2295
|
+
// when a user navigates the URL directly. SVGs still preview as
|
|
2296
|
+
// "binary" placeholder via the JSON path.
|
|
2297
|
+
if (ext === "svg") {
|
|
2298
|
+
sendJson(res, {
|
|
2299
|
+
error: "SVG raw rendering disabled — script-injection risk. Use the binary placeholder.",
|
|
2300
|
+
}, 415, req);
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
const mime = {
|
|
2304
|
+
png: "image/png",
|
|
2305
|
+
jpg: "image/jpeg",
|
|
2306
|
+
jpeg: "image/jpeg",
|
|
2307
|
+
gif: "image/gif",
|
|
2308
|
+
webp: "image/webp",
|
|
2309
|
+
ico: "image/x-icon",
|
|
2310
|
+
pdf: "application/pdf",
|
|
2311
|
+
};
|
|
2312
|
+
const contentType = mime[ext];
|
|
2313
|
+
if (!contentType) {
|
|
2314
|
+
// Unknown ext + raw=1 — refuse rather than serve as octet-stream
|
|
2315
|
+
// (which the browser may sniff into a script context).
|
|
2316
|
+
sendJson(res, { error: `raw=1 not supported for extension: ${ext || "<none>"}` }, 415, req);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
res.setHeader("Content-Type", contentType);
|
|
2320
|
+
res.setHeader("Content-Length", String(buf.length));
|
|
2321
|
+
res.setHeader("Cache-Control", "private, max-age=60");
|
|
2322
|
+
// 0823 F-002 (defense in depth): block content-type sniffing AND force
|
|
2323
|
+
// an inline-only Content-Disposition so the browser cannot reinterpret
|
|
2324
|
+
// the response as HTML/script.
|
|
2325
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
2326
|
+
res.setHeader("Content-Disposition", "inline");
|
|
2327
|
+
res.writeHead(200);
|
|
2328
|
+
res.end(buf);
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
// Binary detection: check first 8KB for null bytes
|
|
2133
2332
|
const probe = buf.subarray(0, Math.min(8192, buf.length));
|
|
2134
2333
|
for (let i = 0; i < probe.length; i++) {
|
|
2135
2334
|
if (probe[i] === 0) {
|
|
@@ -2159,7 +2358,9 @@ export function registerRoutes(router, root, projectName) {
|
|
|
2159
2358
|
return;
|
|
2160
2359
|
}
|
|
2161
2360
|
const fullPath = resolve(join(skillDir, filePath));
|
|
2162
|
-
|
|
2361
|
+
// 0823 F-002 (security): trailing-separator-aware containment via shared
|
|
2362
|
+
// helper — same guard as /file (read).
|
|
2363
|
+
if (!isContainedIn(fullPath, skillDir)) {
|
|
2163
2364
|
sendJson(res, { error: "Path traversal denied" }, 403, req);
|
|
2164
2365
|
return;
|
|
2165
2366
|
}
|
|
@@ -3162,6 +3363,112 @@ Return ONLY the JSON lines, no other text.`;
|
|
|
3162
3363
|
const skillDependencies = detectSkillDependencies(content);
|
|
3163
3364
|
sendJson(res, { mcpDependencies, skillDependencies }, 200, req);
|
|
3164
3365
|
});
|
|
3366
|
+
// 0820 — Reveal in Editor / Open. Spawns the user's preferred editor on
|
|
3367
|
+
// the skill folder (Open) or its SKILL.md (Reveal/Edit). Path-traversal
|
|
3368
|
+
// hardened: never trusts a client-supplied dir; resolves (plugin, skill)
|
|
3369
|
+
// server-side via scanSkillInstallLocations and rejects any non-basename
|
|
3370
|
+
// `file`. Spawn is detached + unref'd so the response returns immediately.
|
|
3371
|
+
router.post("/api/skills/reveal-in-editor", async (req, res) => {
|
|
3372
|
+
let body;
|
|
3373
|
+
try {
|
|
3374
|
+
body = (await readBody(req));
|
|
3375
|
+
}
|
|
3376
|
+
catch {
|
|
3377
|
+
// Malformed JSON or oversized body — surface as 400 invalid_body
|
|
3378
|
+
// rather than letting the router emit a generic 500.
|
|
3379
|
+
sendJson(res, { error: "invalid_body" }, 400, req);
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
const plugin = typeof body.plugin === "string" ? body.plugin : undefined;
|
|
3383
|
+
const skill = typeof body.skill === "string" ? body.skill : undefined;
|
|
3384
|
+
if (plugin === undefined || skill === undefined || skill.length === 0) {
|
|
3385
|
+
sendJson(res, { error: "invalid_body" }, 400, req);
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
let file;
|
|
3389
|
+
if (body.file !== undefined) {
|
|
3390
|
+
// Reject: non-string, empty, embeds either separator (POSIX `/` or
|
|
3391
|
+
// Windows `\`), `.` / `..` as filename, or any control char. We check
|
|
3392
|
+
// both separators explicitly since path.basename on POSIX doesn't
|
|
3393
|
+
// recognize `\` as a separator, and the eval-server may run on either.
|
|
3394
|
+
const f = body.file;
|
|
3395
|
+
const invalid = typeof f !== "string" ||
|
|
3396
|
+
f.length === 0 ||
|
|
3397
|
+
f === "." ||
|
|
3398
|
+
f === ".." ||
|
|
3399
|
+
f.includes("/") ||
|
|
3400
|
+
f.includes("\\") ||
|
|
3401
|
+
// eslint-disable-next-line no-control-regex
|
|
3402
|
+
/[\x00-\x1f]/.test(f);
|
|
3403
|
+
if (invalid) {
|
|
3404
|
+
sendJson(res, { error: "invalid_file" }, 400, req);
|
|
3405
|
+
return;
|
|
3406
|
+
}
|
|
3407
|
+
file = f;
|
|
3408
|
+
}
|
|
3409
|
+
const canonical = plugin ? `${plugin}/${skill}` : skill;
|
|
3410
|
+
const winner = pickHighestPrecedenceLocation(scanSkillInstallLocations(canonical, root));
|
|
3411
|
+
if (!winner) {
|
|
3412
|
+
sendJson(res, { error: "skill_not_found" }, 404, req);
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
const dir = winner.dir;
|
|
3416
|
+
// Defense in depth: scanSkillInstallLocations already restricts dirs to
|
|
3417
|
+
// known scope roots, but a final basename check rejects anything that
|
|
3418
|
+
// doesn't end in the requested skill slug — protects against a future
|
|
3419
|
+
// scanner regression that returned a parent or sibling dir.
|
|
3420
|
+
if (basename(dir) !== skill) {
|
|
3421
|
+
sendJson(res, { error: "skill_not_found" }, 404, req);
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
if (file) {
|
|
3425
|
+
const finalPath = join(dir, file);
|
|
3426
|
+
// TOCTOU: file may be deleted between this check and spawn. We accept
|
|
3427
|
+
// that — editors handle a vanished path by opening an empty buffer, and
|
|
3428
|
+
// the alternative (no pre-check) would leak filesystem details via
|
|
3429
|
+
// spawn ENOENT for normal "this skill has no SKILL.md" cases.
|
|
3430
|
+
if (!existsSync(finalPath)) {
|
|
3431
|
+
sendJson(res, { error: "file_not_found" }, 404, req);
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
let launch;
|
|
3436
|
+
try {
|
|
3437
|
+
launch = resolveEditorCommand({ dir, file });
|
|
3438
|
+
}
|
|
3439
|
+
catch (err) {
|
|
3440
|
+
if (err instanceof NoEditorError) {
|
|
3441
|
+
sendJson(res, { error: "no_editor" }, 500, req);
|
|
3442
|
+
}
|
|
3443
|
+
else {
|
|
3444
|
+
// Distinguish "resolver blew up" from "spawn blew up". Same 500
|
|
3445
|
+
// shape, but the error code points to the right phase.
|
|
3446
|
+
sendJson(res, { error: "resolve_failed" }, 500, req);
|
|
3447
|
+
}
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
try {
|
|
3451
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
3452
|
+
const child = spawn(launch.command, launch.args, {
|
|
3453
|
+
detached: true,
|
|
3454
|
+
stdio: "ignore",
|
|
3455
|
+
});
|
|
3456
|
+
child.once("error", rejectPromise);
|
|
3457
|
+
child.once("spawn", () => {
|
|
3458
|
+
child.unref();
|
|
3459
|
+
resolvePromise();
|
|
3460
|
+
});
|
|
3461
|
+
});
|
|
3462
|
+
}
|
|
3463
|
+
catch (err) {
|
|
3464
|
+
// Log so operators can diagnose ENOENT / permission-denied / etc.;
|
|
3465
|
+
// the user-facing toast is the generic 500.
|
|
3466
|
+
console.warn("[reveal-in-editor] spawn failed:", err);
|
|
3467
|
+
sendJson(res, { error: "spawn_failed" }, 500, req);
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
sendJson(res, { ok: true, command: launch.command, args: launch.args }, 200, req);
|
|
3471
|
+
});
|
|
3165
3472
|
// Handle CORS preflight
|
|
3166
3473
|
router.options = (req, res) => {
|
|
3167
3474
|
const origin = req.headers.origin;
|