Skip to content
Merged
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 marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{
"name": "jfrog",
"description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices",
"version": "1.0.23",
"version": "1.0.24",
"license": "Apache-2.0",
"source": "plugin",
"categories": [
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jfrog",
"description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices",
"version": "1.0.23",
"version": "1.0.24",
"license": "Apache-2.0",
"author": {
"name": "JFrog",
Expand Down
67 changes: 55 additions & 12 deletions plugin/skills/jfrog-init/scripts/jfrog-detect-catalog-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// AI Catalog readiness check for the current user + JPD, against
// <JPD>/ml/core/api/v1/mcp-registry/ml-projects?pageSize=1 — this skill
// does NOT read a separate JFROG_PLATFORM_URL / JFROG_URL env var; the
// source of truth is what `jf` itself is configured with.
// source of truth is what `jf` itself is configured with. A 404 there is
// retried once behind <JPD>/bridge-client, where self-hosted JPDs serve
// the same API (see BRIDGE_CLIENT_PREFIX below).
//
// Two sub-checks, both must pass (mirrors jfrog-detect-server-ping.mjs's
// reachability/credentials split):
Expand Down Expand Up @@ -51,6 +53,17 @@ import { emit, isMainModule, resolveCreds, urlForServer, normalizeJpdUrl, authed
import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs";

const CATALOG_PATH = "/ml/core/api/v1/mcp-registry/ml-projects?pageSize=1";
// Self-hosted JPDs serve the same API behind `/bridge-client`. Tried ONLY
// after the root path 404s, so SaaS still costs one request per part.
const BRIDGE_CLIENT_PREFIX = "/bridge-client";

// The anonymous codes that let Part A proceed to Part B. Shared by the Part A
// gate and its `/bridge-client` fallback so the two can't drift.
const partAReachable = (code) => /^[23]/.test(code) || ["401", "403", "405", "406"].includes(code);

// A 2xx alone isn't proof this is the AI Catalog — a captive portal or
// misrouted network can answer 200 too. Require the expected shape.
const looksLikeCatalog = (body) => Boolean(body) && typeof body === "object" && Array.isArray(body.projectKeys);

// Shared by both Part A (anonymous) and Part B (authenticated) below — each
// probe can independently come back "000" (connection failed) or "404"
Expand Down Expand Up @@ -95,10 +108,28 @@ export async function detectCatalogRuntime(serverIdArg) {
emit({ check: "catalog", status: "red", detail: `no url found in jf config for server-id=${serverId}` });
return 1;
}
const endpoint = `${url}${CATALOG_PATH}`;
// Which prefix this JPD serves the catalog under: "" for SaaS,
// `/bridge-client` for self-hosted. Part A resolves it when it can; Part B
// otherwise, since a JPD answering 401 anonymously at the root passes Part
// A without revealing that the authenticated call 404s there.
let prefix = "";
let endpoint = `${url}${CATALOG_PATH}`;

// ---------- Part A: anonymous reachability ----------
const anonCode = await anonymousFetchStatus(endpoint);
let anonCode = await anonymousFetchStatus(endpoint);

// Adopt the fallback only when it evidences a deployed catalog. Anything
// else (a proxy's 400/501, a 5xx, a failed connection) leaves the root 404
// verdict — and its non-blocking exit 1 — exactly as it was.
if (anonCode === "404") {
const bridgeEndpoint = `${url}${BRIDGE_CLIENT_PREFIX}${CATALOG_PATH}`;
const bridgeCode = await anonymousFetchStatus(bridgeEndpoint);
if (partAReachable(bridgeCode)) {
prefix = BRIDGE_CLIENT_PREFIX;
endpoint = bridgeEndpoint;
anonCode = bridgeCode;
}
}

if (anonCode === "000") {
return emitUnreachable(endpoint);
Expand All @@ -109,7 +140,7 @@ export async function detectCatalogRuntime(serverIdArg) {
if (/^5/.test(anonCode)) {
return emitServerError(endpoint, anonCode);
}
if (!/^2/.test(anonCode) && !/^3/.test(anonCode) && !["401", "403", "405", "406"].includes(anonCode)) {
if (!partAReachable(anonCode)) {
emit({ check: "catalog", status: "error", detail: `catalog probe returned unexpected HTTP ${anonCode} at ${endpoint}` });
return 3;
}
Expand All @@ -126,19 +157,31 @@ export async function detectCatalogRuntime(serverIdArg) {
return 1;
}

const { code, body } = await authedFetch(creds, CATALOG_PATH);
const httpCode = code === 0 ? "000" : String(code);
const authed = await authedFetch(creds, `${prefix}${CATALOG_PATH}`);
let body = authed.body;
let httpCode = authed.code === 0 ? "000" : String(authed.code);

// A 2xx status alone isn't proof this is really the AI Catalog endpoint —
// a captive portal or misrouted network can also answer 200. Require the
// expected shape (an object with a `projectKeys` array) too.
const looksLikeCatalog = body && typeof body === "object" && Array.isArray(body.projectKeys);
// Part A passed at the root (e.g. an anonymous 401) but the authenticated
// call 404s there — the same self-hosted layout, one part later. Adopted
// only on proof of a catalog: a 2xx of the right shape, or the 403 that
// means "deployed, this user isn't entitled". A WAF's 401 or a captive 200
// must NOT win, or a fine set of credentials gets reported as rejected.
// Adopting leaves only the green and not_entitled branches reachable, and
// neither reports `endpoint`, so it stays the root path it was built from.
if (httpCode === "404" && !prefix) {
const retry = await authedFetch(creds, `${BRIDGE_CLIENT_PREFIX}${CATALOG_PATH}`);
const retryCode = retry.code === 0 ? "000" : String(retry.code);
if ((/^2/.test(retryCode) && looksLikeCatalog(retry.body)) || retryCode === "403") {
body = retry.body;
httpCode = retryCode;
}
}

if (httpCode.startsWith("2") && looksLikeCatalog) {
if (httpCode.startsWith("2") && looksLikeCatalog(body)) {
emit({ check: "catalog", status: "green", detail: `catalog reachable, user entitled (HTTP ${httpCode})` });
return 0;
}
if (httpCode.startsWith("2") && !looksLikeCatalog) {
if (httpCode.startsWith("2") && !looksLikeCatalog(body)) {
emit({ check: "catalog", status: "error", detail: `got HTTP ${httpCode} from ${endpoint} but the response wasn't the expected AI Catalog shape — this may not be the JPD's real endpoint (captive portal / proxy?)` });
return 3;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import process from "node:process";

const SETTINGS_PATH =
"/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled";
// Self-hosted JPDs serve the same API behind `/bridge-client`. Tried ONLY
// after the root path 404s, so SaaS still costs exactly one request.
const BRIDGE_CLIENT_PREFIX = "/bridge-client";
const REQUEST_TIMEOUT_MS = 5000;

const debugEnabled = process.env.JF_AGENT_GUARD_DEBUG === "true";
Expand Down Expand Up @@ -173,14 +176,38 @@ function resolveFromCliConfig(serverId) {
return { baseUrl, token, source: `JF CLI config (server '${id}')` };
}

/** Drops the internal `notFound` marker from a fetchSetting() result. */
function strip({ notFound, ...result }) {
return result;
}

async function isGatewayPluginEnabled(baseUrl, token) {
// Normalize to the platform root: drop trailing slashes and a trailing
// `/artifactory` segment. Users commonly export JFROG_URL as
// `https://myco.jfrog.io/artifactory`, but the settings path lives under
// `/ml/core` off the platform root — without this, Path A would build
// `.../artifactory/ml/core/...` and 404 into a false "disabled" (exit 1).
// `.../artifactory/ml/core/...` and 404 into a false "unknown" (exit 1).
const root = baseUrl.replace(/\/+$/, "").replace(/\/artifactory$/, "");
const url = root + SETTINGS_PATH;

const rootResult = await fetchSetting(root + SETTINGS_PATH, token);
if (!rootResult.notFound) return strip(rootResult);

// Root 404 -> possibly self-hosted. Each attempt gets its OWN timeout
// budget: a reused AbortController would start the retry already spent.
debug(`Root ${SETTINGS_PATH} returned 404; retrying behind ${BRIDGE_CLIENT_PREFIX}.`);
const bridgeResult = await fetchSetting(
root + BRIDGE_CLIENT_PREFIX + SETTINGS_PATH,
token,
);
// Bridge may only UPGRADE the verdict; anything else keeps the root result.
if (bridgeResult.ok || bridgeResult.registryOff) return strip(bridgeResult);
return strip(rootResult);
}

// One HTTP attempt against a fully-built settings URL. `notFound` marks the
// 404 that triggers the `/bridge-client` retry; callers strip it before
// returning so the result shape main() sees is unchanged.
async function fetchSetting(url, token) {
debug(`Fetching gateway plugin setting from ${url}`);

// Trade-off: we use a direct fetch() rather than `jf api` (the pattern other
Expand Down Expand Up @@ -210,6 +237,7 @@ async function isGatewayPluginEnabled(baseUrl, token) {
// claiming disabled. Only HTTP 200 + value:false is "disabled".
return {
ok: false,
notFound: response.status === 404,
reason: `settings endpoint returned HTTP ${response.status}`,
};
}
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/jfrog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ compatibility: >-
(CLI) and Tier 3 (jf api) operations; without it, only MCP (Tier 1) is available.
metadata:
role: base
version: "0.35.0"
version: "0.36.0"
---

# JFrog Skill
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/jfrog/scripts/check-environment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ canonical_agent_name() {
windsurf) echo "windsurf" ;;
aider) echo "aider" ;;
cline) echo "cline" ;;
opencode) echo "opencode" ;;
opencode|opencode-jfrog-plugin) echo "opencode" ;;
amp) echo "amp" ;;
augment) echo "augment" ;;
qwen-code|qwen) echo "qwen" ;;
Expand Down
Loading