From fdf90bf173204883be6dd67d047ee8bed9498d81 Mon Sep 17 00:00:00 2001 From: "jfrog-agent-hooks-sync[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:45:22 +0000 Subject: [PATCH 1/2] chore: sync modules to v0.8.0 --- .cursor-plugin/marketplace.json | 2 +- .github/scripts/sync-modules-vendor.json | 2 +- plugins/jfrog/.cursor-plugin/plugin.json | 2 +- .../modules/assets/agents-default-conf.json | 1 + plugins/jfrog/modules/core/io.mjs | 55 ++++-- plugins/jfrog/modules/core/jf-identity.mjs | 36 ++-- .../scripts/eager-setup-receipt.mjs | 75 +++++--- .../scripts/eager-setup.mjs | 163 ++++++++++------ .../scripts/feature-flag.mjs | 22 ++- .../scripts/package-manager-family.mjs | 176 ++++++++++++++++++ .../scripts/render-instruction.mjs | 67 +++++-- .../package-resolution/scripts/repo-types.mjs | 12 +- .../package-resolution/scripts/resolver.mjs | 1 + .../package-resolution-unconfigured.md | 10 +- .../templates/package-resolution.md | 17 +- 15 files changed, 499 insertions(+), 142 deletions(-) create mode 100644 plugins/jfrog/modules/package-resolution/scripts/package-manager-family.mjs diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 974068e..06d0b0a 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "JFrog Platform plugins for Cursor", - "version": "0.5.10", + "version": "0.5.11", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 3f7cfad..a4e6851 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -1,6 +1,6 @@ { "repo": "JFROG/jfrog-agent-hooks", - "pin": "jfrog-agent-hooks/v0.7.0", + "pin": "jfrog-agent-hooks/v0.8.0", "paths": [ "modules" ] diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index 027a89f..11f8431 100644 --- a/plugins/jfrog/.cursor-plugin/plugin.json +++ b/plugins/jfrog/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "displayName": "JFrog Platform", - "version": "0.5.10", + "version": "0.5.11", "description": "JFrog Platform integration with MCP, security skills, Agent Package Resolution, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.", "author": { "name": "JFrog", diff --git a/plugins/jfrog/modules/assets/agents-default-conf.json b/plugins/jfrog/modules/assets/agents-default-conf.json index 825351b..3d13fce 100644 --- a/plugins/jfrog/modules/assets/agents-default-conf.json +++ b/plugins/jfrog/modules/assets/agents-default-conf.json @@ -8,6 +8,7 @@ "npm": "npm-virtual", "pypi": "pypi-virtual", "maven": "maven-virtual", + "gradle": "gradle-virtual", "go": "go-virtual", "docker": "docker-virtual", "helm": "helm-virtual", diff --git a/plugins/jfrog/modules/core/io.mjs b/plugins/jfrog/modules/core/io.mjs index 5127bae..13db450 100644 --- a/plugins/jfrog/modules/core/io.mjs +++ b/plugins/jfrog/modules/core/io.mjs @@ -6,30 +6,59 @@ import process from "node:process"; +/** A whole payload has arrived, as opposed to a prefix of one. */ +function isCompletePayload(text) { + let value; + try { + value = JSON.parse(text); + } catch { + return false; // still mid-payload + } + // Objects only: a truncated object never parses, but a truncated number + // does, so `12` arriving out of `1234` must not look finished. + return typeof value === "object" && value !== null; +} + +// Releasing the stream matters as much as reading it. A 'data' listener puts +// stdin in flowing mode, which keeps the handle referenced and the process +// alive even after the hook has written its answer. A caller that holds the +// pipe open would otherwise hang us until the harness kills the process — +// which, on a fail-closed hook, denies the tool call. +// +// The same caller costs us latency even when nothing hangs: waiting out the +// idle window on every preToolUse call added ~60ms to each of the agent's +// shell commands. A hook payload is one JSON object, so once it parses there +// is nothing left to wait for and we stop reading immediately. export function readStdin({ idleMs = 50 } = {}) { return new Promise((resolve) => { if (process.stdin.isTTY) return resolve(""); let data = ""; let settled = false; + let idleTimer; + + const onData = (chunk) => { + data += chunk; + if (isCompletePayload(data)) settle(); + else idleTimer.refresh(); + }; + const settle = () => { if (settled) return; settled = true; + clearTimeout(idleTimer); + process.stdin.off("data", onData); + process.stdin.off("end", settle); + process.stdin.off("error", settle); + process.stdin.pause(); + process.stdin.unref?.(); resolve(data); }; - const idleTimer = setTimeout(settle, idleMs); + + idleTimer = setTimeout(settle, idleMs); process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { - data += chunk; - idleTimer.refresh(); - }); - process.stdin.on("end", () => { - clearTimeout(idleTimer); - settle(); - }); - process.stdin.on("error", () => { - clearTimeout(idleTimer); - settle(); - }); + process.stdin.on("data", onData); + process.stdin.on("end", settle); + process.stdin.on("error", settle); }); } diff --git a/plugins/jfrog/modules/core/jf-identity.mjs b/plugins/jfrog/modules/core/jf-identity.mjs index 1ffa006..b910d39 100644 --- a/plugins/jfrog/modules/core/jf-identity.mjs +++ b/plugins/jfrog/modules/core/jf-identity.mjs @@ -21,6 +21,14 @@ import { createLogger } from "./logger.mjs"; const log = createLogger("jf-identity"); +// Wire-format cause codes for getPlatformIdentity() / pending remediation. +// Single source of truth — import this instead of repeating string literals. +export const IdentityCause = Object.freeze({ + OK: "ok", + JF_NOT_INSTALLED: "jf-not-installed", + JF_NOT_CONFIGURED: "jf-not-configured", +}); + // Module-scope cache. Keyed by the requested serverId hint (`undefined` // means "whatever jf considers default"). Stores the full resolved object, // including null when jf config produced nothing usable. @@ -31,12 +39,12 @@ function normalizeUrl(u) { return String(u).replace(/\/+$/, ""); } -// Resolution cause. `ok` means identity is present; the two failure causes +// Resolution cause. OK means identity is present; the two failure causes // drive cause-aware remediation in the pending path: -// jf-not-installed — `jf` is not on PATH / could not be executed. -// jf-not-configured — `jf` ran but produced no usable server identity -// (non-zero exit, empty/undecodable export, or a -// server entry missing url/accessToken). +// JF_NOT_INSTALLED — `jf` is not on PATH / could not be executed. +// JF_NOT_CONFIGURED — `jf` ran but produced no usable server identity +// (non-zero exit, empty/undecodable export, or a +// server entry missing url/accessToken). function jfConfigIdentity(serverId) { // `jf config export` writes base64(JSON) to stdout for the requested // server (or the default when no arg). We split the failure space into @@ -56,27 +64,27 @@ function jfConfigIdentity(serverId) { }); } catch (err) { log.debug("jf spawn threw", { error: err?.message ?? String(err) }); - return { identity: null, cause: "jf-not-installed" }; + return { identity: null, cause: IdentityCause.JF_NOT_INSTALLED }; } if (result.error) { // ENOENT (and any other spawn error) means the binary could not be // executed — treat as not installed. log.debug("jf spawn error", { code: result.error.code, message: result.error.message }); - return { identity: null, cause: "jf-not-installed" }; + return { identity: null, cause: IdentityCause.JF_NOT_INSTALLED }; } if (result.status !== 0) { log.debug("jf config export non-zero exit", { status: result.status, stderr: (result.stderr || "").trim().slice(0, 200), }); - return { identity: null, cause: "jf-not-configured" }; + return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; } const blob = (result.stdout || "").trim(); if (!blob) { log.debug("jf config export returned empty stdout"); - return { identity: null, cause: "jf-not-configured" }; + return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; } let parsed; @@ -85,7 +93,7 @@ function jfConfigIdentity(serverId) { parsed = JSON.parse(json); } catch (err) { log.warn("jf config export blob not decodable", { error: err?.message ?? String(err) }); - return { identity: null, cause: "jf-not-configured" }; + return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; } const url = normalizeUrl(parsed?.url); @@ -98,18 +106,18 @@ function jfConfigIdentity(serverId) { hasUrl: Boolean(url), hasToken: Boolean(token), }); - return { identity: null, cause: "jf-not-configured" }; + return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; } return { identity: { url, token, serverId: resolvedServerId, source: "jf-config" }, - cause: "ok", + cause: IdentityCause.OK, }; } // Public — returns { identity, cause }: // identity: { url, token, serverId, source } | null -// cause: "ok" | "jf-not-installed" | "jf-not-configured" +// cause: IdentityCause.OK | JF_NOT_INSTALLED | JF_NOT_CONFIGURED export function getPlatformIdentity() { const hint = undefined; if (CACHE.has(hint)) return CACHE.get(hint); @@ -155,7 +163,7 @@ if (isMain) { } if (!identity) { const hint = - cause === "jf-not-installed" + cause === IdentityCause.JF_NOT_INSTALLED ? "`jf` is not installed. Install the JFrog CLI, then run `jf config add`." : "No configured JFrog server. Run `jf config add` (must use access-token auth)."; console.error(`No platform identity (${cause}). ${hint}`); diff --git a/plugins/jfrog/modules/package-resolution/scripts/eager-setup-receipt.mjs b/plugins/jfrog/modules/package-resolution/scripts/eager-setup-receipt.mjs index 085d0cc..f99ad65 100644 --- a/plugins/jfrog/modules/package-resolution/scripts/eager-setup-receipt.mjs +++ b/plugins/jfrog/modules/package-resolution/scripts/eager-setup-receipt.mjs @@ -1,18 +1,29 @@ // Eager-setup receipt — durable "already configured via `jf setup`" ledger. // -// `jf setup` mutates USER-GLOBAL PM config (`~/.npmrc`, `~/.docker/config.json`, -// …), not per-workspace state, so the skip decision keys on `serverId + type` -// (NOT workspace). This is a dedicated file — separate from the resolver cache -// (different key granularity + invalidation; the resolver's normalizer would -// strip these co-located fields). +// `jf setup` mutates USER-GLOBAL package-manager config (`~/.npmrc`, +// `~/.docker/config.json`, …), not per-workspace state, so the skip decision +// keys on `serverId + packageManager` (NOT workspace, NOT Artifactory package +// type). One governed type can own several package managers (pypi → +// pip/pipenv/uv); each gets its own receipt entry so status stays honest +// (Option C). +// +// Schema 2 = package-manager-keyed entries only. Prior schema (type-keyed or +// mixed) is discarded on read — overlapping names like `npm`/`go`/`docker` +// were not distinguishable from package-manager keys, so a wipe + idempotent +// `jf setup` re-run is the safe migration. +// +// This is a dedicated file — separate from the resolver cache (different key +// granularity + invalidation; the resolver's normalizer would strip these +// co-located fields). // // File: ~/.jfrog/skills-cache/package-setup.json // { -// "schemaVersion": 1, +// "schemaVersion": 2, // "servers": { // "": { // "url": "https://corp.jfrog.io", -// "npm": { "repoKey": "npm-virtual", "status": "ok", "configuredAt": "..." } +// "pip": { "repoKey": "pypi-virtual", "status": "ok", "configuredAt": "..." }, +// "uv": { "repoKey": "pypi-virtual", "status": "ok", "configuredAt": "..." } // } // } // } @@ -25,9 +36,9 @@ import { createLogger } from "../../core/logger.mjs"; const log = createLogger("eager-setup-receipt"); -const RECEIPT_SCHEMA_VERSION = 1; +const RECEIPT_SCHEMA_VERSION = 2; -// Reserved key inside a server entry (everything else is a package-type entry). +// Reserved key inside a server entry (everything else is a package-manager receipt). const RESERVED_KEYS = new Set(["url"]); function cacheDir() { @@ -38,6 +49,10 @@ function receiptFile() { return path.join(cacheDir(), "package-setup.json"); } +function emptyReceipt() { + return { schemaVersion: RECEIPT_SCHEMA_VERSION, servers: {} }; +} + function normalizeTypeEntry(entry) { if (!entry || typeof entry !== "object") return null; if (typeof entry.repoKey !== "string" || !entry.repoKey) return null; @@ -56,13 +71,15 @@ function normalizeTypeEntry(entry) { /** Normalize raw on-disk JSON to `{ schemaVersion, servers }`; drop junk. */ export function normalizeReceipt(data) { - const servers = {}; if ( - data && - typeof data === "object" && - data.servers && - typeof data.servers === "object" + !data || + typeof data !== "object" || + data.schemaVersion !== RECEIPT_SCHEMA_VERSION ) { + return emptyReceipt(); + } + const servers = {}; + if (data.servers && typeof data.servers === "object") { for (const [serverId, raw] of Object.entries(data.servers)) { if (!raw || typeof raw !== "object") continue; const entry = {}; @@ -83,7 +100,7 @@ export async function readReceipt() { const raw = await readFile(receiptFile(), "utf8"); return normalizeReceipt(JSON.parse(raw)); } catch { - return { schemaVersion: RECEIPT_SCHEMA_VERSION, servers: {} }; + return emptyReceipt(); } } @@ -104,7 +121,8 @@ export async function writeReceipt(root) { // `ttlDays <= 0` means "no time-based re-run" — the entry only becomes stale on // a repoKey/server change, never on a timer. (This differs from the resolver // cache, where 0 forces a cheap re-resolve every session; re-running `jf setup` -// every session would thrash user-global PM config, so 0 here means unbounded.) +// every session would thrash user-global package-manager config, so 0 here +// means unbounded.) function receiptWithinTtl(configuredAt, ttlDays) { if (!configuredAt) return false; if (typeof ttlDays !== "number" || !Number.isFinite(ttlDays) || ttlDays <= 0) @@ -115,7 +133,7 @@ function receiptWithinTtl(configuredAt, ttlDays) { } /** - * Decide whether `jf setup` can be SKIPPED for one (serverId, type). + * Decide whether `jf setup` can be SKIPPED for one (serverId, packageManager). * * A recorded result — success OR failure — is trusted for `ttlDays` (the unified * `cacheTtlDays`). So a persistent failure is retried at most once per TTL @@ -128,13 +146,13 @@ function receiptWithinTtl(configuredAt, ttlDays) { */ export function evaluateSetupNeed( receipt, - { serverId, url, type, repoKey, ttlDays }, + { serverId, url, packageManager, ttlDays, repoKey }, ) { const server = receipt?.servers?.[serverId]; if (!server) return { skip: false, reason: "no-receipt" }; if (url && server.url && server.url !== url) return { skip: false, reason: "server-url-changed" }; - const entry = server[type]; + const entry = server[packageManager]; if (!entry) return { skip: false, reason: "no-entry" }; // A changed repo key means the admin/workspace fixed the target — retry now, // whether the previous result was ok or failed. @@ -151,30 +169,35 @@ export function evaluateSetupNeed( return { skip: true, reason: "receipt-hit" }; } -/** Read the recorded entry for (serverId, type), or null. */ -export function receiptEntry(receipt, serverId, type) { - return receipt?.servers?.[serverId]?.[type] ?? null; +/** Read the recorded entry for (serverId, packageManager), or null. */ +export function receiptEntry(receipt, serverId, packageManager) { + return receipt?.servers?.[serverId]?.[packageManager] ?? null; } /** * Merge a single setup result into the receipt object (in place) and return it. * Only status "ok" marks a success; failures are recorded (not as ok) so the - * next session can surface + retry them. + * next session can surface + retry them. Keyed by `jf setup` package-manager token. */ export function applySetupResult( root, - { serverId, url, type, repoKey, status, reason }, + { serverId, url, packageManager, repoKey, status, reason }, ) { if (!root.servers) root.servers = {}; const server = root.servers[serverId] ?? {}; if (url) server.url = url; - server[type] = { + server[packageManager] = { repoKey, status: status === "ok" ? "ok" : "failed", configuredAt: new Date().toISOString(), ...(reason ? { reason: String(reason).slice(0, 500) } : {}), }; root.servers[serverId] = server; - log.debug("receipt entry staged", { serverId, type, repoKey, status }); + log.debug("receipt entry staged", { + serverId, + packageManager, + repoKey, + status, + }); return root; } diff --git a/plugins/jfrog/modules/package-resolution/scripts/eager-setup.mjs b/plugins/jfrog/modules/package-resolution/scripts/eager-setup.mjs index a180f29..78252d7 100644 --- a/plugins/jfrog/modules/package-resolution/scripts/eager-setup.mjs +++ b/plugins/jfrog/modules/package-resolution/scripts/eager-setup.mjs @@ -7,9 +7,10 @@ // them, and return a short status note for the injected instruction. Never // runs `jf setup` itself — injection must stay fast (< 7s hook budget). // 2. WORKER (background, `node eager-setup.mjs --run `): take a -// global lock, re-check the receipt, run `jf setup --server-id --repo` -// one PM at a time with a per-PM timeout, and record each result. `jf setup` -// mutates USER-GLOBAL PM config, so this is serialized across sessions. +// global lock, re-check the receipt, run `jf setup --server-id --repo` +// one package manager at a time with a per-package-manager timeout, and +// record each result. `jf setup` mutates USER-GLOBAL package-manager +// config, so this is serialized across sessions. // // `jf setup` validates the repo itself (`GET /api/repositories/` + non-zero // exit on bad repo / missing permission), so it is the authoritative check — no @@ -47,6 +48,7 @@ import { evaluateSetupNeed, applySetupResult, } from "./eager-setup-receipt.mjs"; +import { packageManagersForType, packageManagerBinaryOnPath } from "./package-manager-family.mjs"; const log = createLogger("eager-setup"); @@ -60,20 +62,7 @@ function ungovernedAutoSetupHint(type) { ); } -// Package type -> `jf setup` PM token. Validated at runtime against -// `jf setup --help` (support list drifts across CLI versions); unsupported -// mappings are skipped with a warn rather than hardcoded-trusted. -const TYPE_TO_PM = { - npm: "npm", - pypi: "pip", - maven: "maven", - go: "go", - docker: "docker", - helm: "helm", - nuget: "nuget", -}; - -const PER_PM_TIMEOUT_MS = 60_000; +const PER_PACKAGE_MANAGER_TIMEOUT_MS = 60_000; function cacheDir() { return path.join(homedir(), ".jfrog", "skills-cache"); @@ -92,11 +81,13 @@ function workerPath() { // --------------------------------------------------------------------------- /** - * Compute eligible eager-setup jobs = governed ∩ resolved ∩ autoSetup. + * Compute eligible eager-setup jobs = governed ∩ resolved ∩ autoSetup, + * expanded to one job per package manager in that type's family (Option C). * Warns when `autoSetup` names an ungoverned type (ignored, not fatal). + * Binary presence and `jf setup --help` are checked later (orchestrator/worker). * @param {string[]} governed * @param {Record} resolvedByType - * @returns {{type:string, repoKey:string, pm:string}[]} + * @returns {{type:string, repoKey:string, packageManager:string}[]} */ export function computeEligibleJobs(governed, resolvedByType) { const governedSet = new Set(governed); @@ -108,12 +99,14 @@ export function computeEligibleJobs(governed, resolvedByType) { log.debug("eager skip: auto-setup but unresolved", { type }); continue; } - const pm = TYPE_TO_PM[type]; - if (!pm) { - log.warn("eager skip: no jf pm mapping", { type }); + const packageManagers = packageManagersForType(type); + if (!packageManagers.length) { + log.warn("eager skip: no jf package-manager family mapping", { type }); continue; } - jobs.push({ type, repoKey: r.repoKey, pm }); + for (const packageManager of packageManagers) { + jobs.push({ type, repoKey: r.repoKey, packageManager }); + } } // Surface admin misconfig: autoSetup naming a type that isn't governed. const { autoSetup } = loadAgentsConfig().packageResolution; @@ -129,7 +122,17 @@ export function computeEligibleJobs(governed, resolvedByType) { return jobs; } -function statusNote({ configured, pending, deferred }) { +/** + * Build the injected zero-touch status note (package-manager names, not Artifactory types). + * @param {{ + * configured: string[], + * pending: string[], + * deferred: string[], + * skippedMissing?: string[], + * }} parts + * @returns {string} markdown note or "" + */ +function statusNote({ configured, pending, deferred, skippedMissing }) { const parts = []; if (pending.length) { parts.push( @@ -137,7 +140,9 @@ function statusNote({ configured, pending, deferred }) { ); } if (configured.length) { - parts.push(`already configured this session: ${configured.join(", ")}`); + parts.push( + `already configured (cached, skipping re-setup): ${configured.join(", ")}`, + ); } if (deferred.length) { parts.push( @@ -145,11 +150,36 @@ function statusNote({ configured, pending, deferred }) { `expires or once the repo/permission is fixed`, ); } + if (skippedMissing?.length) { + parts.push( + `skipped (package manager binary not on PATH): ${skippedMissing.join(", ")}`, + ); + } if (!parts.length) return ""; return `> **Zero-touch package-manager setup** — ${parts.join("; ")}.`; } -function spawnWorker(payloadB64) { +/** + * Sync-mode `spawnSync` timeout for the eager-setup worker. + * Scales with job count so Option C multi-package-manager runs are not killed mid-way. + * @param {number} jobCount number of `jf setup` jobs in the payload + * @returns {number} timeout in milliseconds + */ +export function syncWorkerTimeoutMs(jobCount) { + return Math.max( + 120_000, + PER_PACKAGE_MANAGER_TIMEOUT_MS * Math.max(jobCount, 1) + 30_000, + ); +} + +/** + * Spawn the background eager-setup worker (detached) or run it synchronously + * when `JFROG_EAGER_SETUP_SYNC=1`. + * @param {string} payloadB64 base64 JSON `{ serverId, url, jobs }` + * @param {number} [jobCount=1] used to size the sync-mode timeout + * @returns {void} + */ +function spawnWorker(payloadB64, jobCount = 1) { // Synchronous mode: deterministic tests + a bounded fallback where detached // survival is unreliable. Otherwise spawn detached and unref so the child // outlives the hook process (runtime is irrelevant to the 7s budget). @@ -157,7 +187,7 @@ function spawnWorker(payloadB64) { spawnSync(process.execPath, [workerPath(), "--run", payloadB64], { stdio: "ignore", env: process.env, - timeout: 120_000, + timeout: syncWorkerTimeoutMs(jobCount), }); return; } @@ -207,26 +237,38 @@ export async function orchestrateEagerSetup(ctx = {}) { const configured = []; const pending = []; const deferred = []; + const skippedMissing = []; const toRun = []; for (const job of jobs) { + // Binary probe in the orchestrator so the injected note can list skips + // before the detached worker runs (PATH walk — no spawn). Worker re-checks. + if (!packageManagerBinaryOnPath(job.packageManager)) { + skippedMissing.push(job.packageManager); + log.warn("eager skip: package manager binary not on PATH", { + type: job.type, + packageManager: job.packageManager, + }); + continue; + } const need = evaluateSetupNeed(receipt, { serverId, url, - type: job.type, + packageManager: job.packageManager, repoKey: job.repoKey, ttlDays: cacheTtlDays, }); if (need.skip) { // "failed-deferred" = a still-failing entry within its TTL: don't retry // this session (no jf setup, no WARN), but surface it in the note. - if (need.reason === "failed-deferred") deferred.push(job.type); - else configured.push(job.type); + if (need.reason === "failed-deferred") deferred.push(job.packageManager); + else configured.push(job.packageManager); continue; } - pending.push(job.type); + pending.push(job.packageManager); toRun.push(job); log.debug("eager setup needed", { type: job.type, + packageManager: job.packageManager, repoKey: job.repoKey, reason: need.reason, }); @@ -237,10 +279,10 @@ export async function orchestrateEagerSetup(ctx = {}) { JSON.stringify({ serverId, url, jobs: toRun }), "utf8", ).toString("base64"); - spawnWorker(payload); + spawnWorker(payload, toRun.length); } - return statusNote({ configured, pending, deferred }); + return statusNote({ configured, pending, deferred, skippedMissing }); } catch (err) { log.warn("orchestrateEagerSetup failed", { error: err?.message ?? String(err), @@ -253,10 +295,10 @@ export async function orchestrateEagerSetup(ctx = {}) { // Lock (worker-only, best-effort, one global lock) // --------------------------------------------------------------------------- -// Stale threshold must exceed the total per-PM budget so a healthy long run is +// Stale threshold must exceed the total per-package-manager budget so a healthy long run is // never reclaimed under it. Floor at 120s. -function staleThresholdMs(pmCount) { - return Math.max(PER_PM_TIMEOUT_MS * Math.max(pmCount, 1), 120_000); +function staleThresholdMs(packageManagerCount) { + return Math.max(PER_PACKAGE_MANAGER_TIMEOUT_MS * Math.max(packageManagerCount, 1), 120_000); } function pidAlive(pid) { @@ -277,10 +319,10 @@ function readLock() { } } -function isStaleLock(meta, pmCount) { +function isStaleLock(meta, packageManagerCount) { if (!meta) return true; const ageMs = Date.now() - new Date(meta.startedAt ?? 0).getTime(); - if (ageMs >= staleThresholdMs(pmCount)) return true; + if (ageMs >= staleThresholdMs(packageManagerCount)) return true; if (meta.hostname === hostname() && !pidAlive(meta.pid)) return true; return false; } @@ -305,7 +347,7 @@ function tryWriteLock(serverId) { * Acquire the global lock. Returns true on success. On live contention → false * (skip, don't wait). On a stale lock → reclaim + retry once. */ -function acquireLock(serverId, pmCount) { +function acquireLock(serverId, packageManagerCount) { mkdirSync(cacheDir(), { recursive: true }); try { tryWriteLock(serverId); @@ -318,7 +360,7 @@ function acquireLock(serverId, pmCount) { } } const existing = readLock(); - if (!isStaleLock(existing, pmCount)) { + if (!isStaleLock(existing, packageManagerCount)) { log.debug("lock held by live worker; skipping", { owner: existing?.pid }); return false; } @@ -355,7 +397,7 @@ function releaseLock() { // --------------------------------------------------------------------------- // Parse the `Supported package managers are: a, b, c` line from `jf setup --help`. -function supportedPms() { +function supportedPackageManagers() { try { const res = spawnSync("jf", ["setup", "--help"], { encoding: "utf8", @@ -395,11 +437,11 @@ function extractJfError(stdout, stderr) { return detail.slice(0, 300); } -function runJfSetup(pm, serverId, repoKey) { - const args = ["setup", pm, "--server-id", serverId, "--repo", repoKey]; +function runJfSetup(packageManager, serverId, repoKey) { + const args = ["setup", packageManager, "--server-id", serverId, "--repo", repoKey]; const res = spawnSync("jf", args, { encoding: "utf8", - timeout: PER_PM_TIMEOUT_MS, + timeout: PER_PACKAGE_MANAGER_TIMEOUT_MS, }); if (res.error) { return { ok: false, reason: `spawn error: ${res.error.message}` }; @@ -416,7 +458,7 @@ function runJfSetup(pm, serverId, repoKey) { /** * Background worker body. Acquire lock → re-check receipt → `jf setup` per job → * record results → release lock. Best-effort; never throws to the caller. - * @param {{ serverId:string, url:string, jobs:{type:string,repoKey:string,pm:string}[] }} payload + * @param {{ serverId:string, url:string, jobs:{type:string,repoKey:string,packageManager:string}[] }} payload */ export async function runWorker(payload) { const { serverId, url, jobs } = payload; @@ -428,44 +470,51 @@ export async function runWorker(payload) { // Re-read the receipt UNDER the lock — another worker may have finished // between the foreground spawn and this acquire. const root = await readReceipt(); - const supported = supportedPms(); + const supported = supportedPackageManagers(); for (const job of jobs) { + if (!packageManagerBinaryOnPath(job.packageManager)) { + log.warn("worker skip: package manager binary not on PATH", { + type: job.type, + packageManager: job.packageManager, + }); + continue; + } const need = evaluateSetupNeed(root, { serverId, url, - type: job.type, + packageManager: job.packageManager, repoKey: job.repoKey, ttlDays: cacheTtlDays, }); if (need.skip) { log.debug("worker skip: receipt fresh under lock", { - type: job.type, + packageManager: job.packageManager, reason: need.reason, }); continue; } - if (supported && !supported.has(job.pm)) { - log.warn("worker skip: pm unsupported by jf setup", { + if (supported && !supported.has(job.packageManager)) { + log.warn("worker skip: package manager unsupported by jf setup", { type: job.type, - pm: job.pm, + packageManager: job.packageManager, }); continue; } - const result = runJfSetup(job.pm, serverId, job.repoKey); + const result = runJfSetup(job.packageManager, serverId, job.repoKey); if (result.ok) { applySetupResult(root, { serverId, url, - type: job.type, + packageManager: job.packageManager, repoKey: job.repoKey, status: "ok", }); log.info("jf setup", { serverId, type: job.type, - pm: job.pm, + packageManager: job.packageManager, repoKey: job.repoKey, status: "ok", }); @@ -473,7 +522,7 @@ export async function runWorker(payload) { applySetupResult(root, { serverId, url, - type: job.type, + packageManager: job.packageManager, repoKey: job.repoKey, status: "failed", reason: result.reason, @@ -481,13 +530,13 @@ export async function runWorker(payload) { log.warn("jf setup", { serverId, type: job.type, - pm: job.pm, + packageManager: job.packageManager, repoKey: job.repoKey, status: "failed", reason: result.reason, }); } - // Persist progress after each PM so a crash mid-run keeps prior results. + // Persist progress after each package manager so a crash mid-run keeps prior results. await writeReceipt(root); } } finally { diff --git a/plugins/jfrog/modules/package-resolution/scripts/feature-flag.mjs b/plugins/jfrog/modules/package-resolution/scripts/feature-flag.mjs index f1c6701..ce3ef42 100644 --- a/plugins/jfrog/modules/package-resolution/scripts/feature-flag.mjs +++ b/plugins/jfrog/modules/package-resolution/scripts/feature-flag.mjs @@ -25,7 +25,11 @@ import process from "node:process"; import { createLogger } from "../../core/logger.mjs"; import { getAgentsConfigSection } from "../../core/agents-config.mjs"; -import { getPlatformIdentity, identityLabel } from "../../core/jf-identity.mjs"; +import { + getPlatformIdentity, + identityLabel, + IdentityCause, +} from "../../core/jf-identity.mjs"; const log = createLogger("feature-flag"); @@ -41,12 +45,22 @@ function isEnabledInConfig() { export async function isPackageResolutionEnabled() { if (isEnvDisabled()) { log.debug("off", { reason: "DISABLE" }); - return { mode: "off", reason: "DISABLE", identity: "none", cause: "ok" }; + return { + mode: "off", + reason: "DISABLE", + identity: "none", + cause: IdentityCause.OK, + }; } if (!isEnabledInConfig()) { log.debug("off", { reason: "NOT_ENABLED" }); - return { mode: "off", reason: "NOT_ENABLED", identity: "none", cause: "ok" }; + return { + mode: "off", + reason: "NOT_ENABLED", + identity: "none", + cause: IdentityCause.OK, + }; } const { identity, cause } = getPlatformIdentity(); @@ -60,6 +74,6 @@ export async function isPackageResolutionEnabled() { mode: "routing", reason: "jf-config", identity: identityLabel(identity), - cause: "ok", + cause: IdentityCause.OK, }; } diff --git a/plugins/jfrog/modules/package-resolution/scripts/package-manager-family.mjs b/plugins/jfrog/modules/package-resolution/scripts/package-manager-family.mjs new file mode 100644 index 0000000..318f9fb --- /dev/null +++ b/plugins/jfrog/modules/package-resolution/scripts/package-manager-family.mjs @@ -0,0 +1,176 @@ +// Package-type → `jf setup` package-manager family (Option C multi-package-manager +// zero-touch). +// +// Governance is keyed by Artifactory repo *type*; eager setup and rewrite +// guidance act on *package managers*. One type may own several (pypi → pip, +// pipenv, uv). Intersect with `jf setup --help` at runtime — this map is a +// ceiling, not a hardcode of CLI support. +// +// `twine` is intentionally excluded from the pypi family (publish-only; not +// part of zero-touch install routing). `yarn` and `poetry` are omitted (not +// first-class in Fly Desktop / product support). Gradle is its own Artifactory +// package type — not folded under maven. + +import { accessSync, constants, statSync } from "node:fs"; +import path from "node:path"; + +/** + * Artifactory package type → `jf setup` package-manager family + * (ceiling; intersect with CLI help). `twine` omitted from `pypi`. + * @type {Readonly>} + */ +export const TYPE_TO_PACKAGE_MANAGERS = Object.freeze({ + npm: Object.freeze(["npm", "pnpm"]), + pypi: Object.freeze(["pip", "pipenv", "uv"]), + maven: Object.freeze(["maven"]), + gradle: Object.freeze(["gradle"]), + go: Object.freeze(["go"]), + docker: Object.freeze(["docker", "podman"]), + helm: Object.freeze(["helm"]), + nuget: Object.freeze(["nuget", "dotnet"]), +}); + +/** + * `jf setup` package-manager token → PATH binary name(s). First hit wins. + * `pip` requires the pip CLI (`pip3`/`pip`) — `jf setup pip` runs + * `pip config set` (not a bare Python write). + * @type {Readonly>} + */ +const PACKAGE_MANAGER_BINARIES = Object.freeze({ + npm: ["npm"], + pnpm: ["pnpm"], + pip: ["pip3", "pip"], + pipenv: ["pipenv"], + uv: ["uv"], + maven: ["mvn"], + gradle: ["gradle"], + go: ["go"], + docker: ["docker"], + podman: ["podman"], + helm: ["helm"], + nuget: ["nuget"], + dotnet: ["dotnet"], +}); + +/** + * Package managers whose `jf setup` only writes config files (settings.xml / + * Gradle init) and never shells out to the client. Wrapper-only projects + * (`./mvnw`, `./gradlew`) must still get zero-touch config — do not PATH-gate. + * @type {ReadonlySet} + */ +const PACKAGE_MANAGERS_SETUP_WITHOUT_CLIENT = new Set(["maven", "gradle"]); + +/** + * Package managers to attempt for a governed package type (empty if unknown). + * @param {string} type Artifactory package type (e.g. `pypi`, `npm`) + * @returns {readonly string[]} `jf setup` package-manager tokens for that type + */ +export function packageManagersForType(type) { + return TYPE_TO_PACKAGE_MANAGERS[type] ?? []; +} + +/** + * Whether a package manager is eligible for eager `jf setup` w.r.t. client + * availability. Missing required binary → skip (warn); no failed receipt. + * + * Uses a PATH directory walk (no `which`/`where` spawn) so sessionStart can + * probe the full family without burning the hook budget. On Windows, also + * tries `PATHEXT` suffixes (`.cmd`, `.exe`, …). + * + * Test hooks: + * - `JFROG_TEST_ASSUME_PACKAGE_MANAGERS_PRESENT=1` → all present (unless listed missing) + * - `JFROG_TEST_MISSING_PACKAGE_MANAGERS=uv,pipenv` → force those absent + * Legacy aliases `JFROG_TEST_ASSUME_PMS_PRESENT` / `JFROG_TEST_MISSING_PMS` still work. + * + * @param {string} packageManager `jf setup` package-manager token + * @returns {boolean} + */ +export function packageManagerBinaryOnPath(packageManager) { + const missingRaw = + process.env.JFROG_TEST_MISSING_PACKAGE_MANAGERS || + process.env.JFROG_TEST_MISSING_PMS || + ""; + const missing = new Set( + missingRaw + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + ); + if (missing.has(String(packageManager).toLowerCase())) return false; + if ( + process.env.JFROG_TEST_ASSUME_PACKAGE_MANAGERS_PRESENT === "1" || + process.env.JFROG_TEST_ASSUME_PMS_PRESENT === "1" + ) { + return true; + } + + if (PACKAGE_MANAGERS_SETUP_WITHOUT_CLIENT.has(packageManager)) return true; + + const bins = PACKAGE_MANAGER_BINARIES[packageManager]; + if (!bins?.length) return false; + for (const bin of bins) { + if (binaryOnPath(bin)) return true; + } + return false; +} + +/** @type {string[] | null} */ +let cachedPathDirs = null; + +/** @returns {string[]} directories from `PATH` (cached for the process) */ +function pathDirs() { + if (cachedPathDirs) return cachedPathDirs; + cachedPathDirs = (process.env.PATH || "") + .split(path.delimiter) + .filter(Boolean); + return cachedPathDirs; +} + +/** + * Reset PATH cache (tests that mutate PATH between checks). + * @returns {void} + */ +export function resetPathCacheForTests() { + cachedPathDirs = null; +} + +/** + * Basenames to try for a command on this platform. + * Windows needs `npm.cmd` / `docker.exe` via PATHEXT; POSIX uses the bare name. + * @param {string} bin + * @returns {string[]} + */ +function pathCandidateNames(bin) { + if (process.platform !== "win32") return [bin]; + const lower = bin.toLowerCase(); + const exts = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM") + .split(";") + .map((e) => e.trim()) + .filter(Boolean); + if (exts.some((ext) => lower.endsWith(ext.toLowerCase()))) return [bin]; + return [bin, ...exts.map((ext) => bin + ext.toLowerCase())]; +} + +/** + * True if `bin` exists as an executable regular file in any PATH directory. + * On Windows, also matches `bin.cmd` / `bin.exe` via PATHEXT. + * @param {string} bin executable basename + * @returns {boolean} + */ +function binaryOnPath(bin) { + const names = pathCandidateNames(bin); + for (const dir of pathDirs()) { + for (const name of names) { + try { + const candidate = path.join(dir, name); + const st = statSync(candidate); + if (!st.isFile()) continue; + accessSync(candidate, constants.X_OK); + return true; + } catch { + // missing, not a file, not executable, or unreadable dir + } + } + } + return false; +} diff --git a/plugins/jfrog/modules/package-resolution/scripts/render-instruction.mjs b/plugins/jfrog/modules/package-resolution/scripts/render-instruction.mjs index 2819037..d55c9c9 100644 --- a/plugins/jfrog/modules/package-resolution/scripts/render-instruction.mjs +++ b/plugins/jfrog/modules/package-resolution/scripts/render-instruction.mjs @@ -21,6 +21,7 @@ import { } from "./resolver.mjs"; import { createLogger } from "../../core/logger.mjs"; import { globalDeclaredTypes } from "../../core/agents-config.mjs"; +import { IdentityCause } from "../../core/jf-identity.mjs"; const log = createLogger("render-instruction"); @@ -36,9 +37,19 @@ function refreshCommand() { return `node "${path.join(here, "print-policy.mjs")}"`; } +// Opening-clause fragment for the pending-notice {{CAUSE_INTRO}} placeholder. +// Kept in sync with causeRemediation / causeChecklist so the notice never +// contradicts itself (intro vs remediation vs numbered steps). +function causeIntro(cause) { + if (cause === IdentityCause.JF_NOT_INSTALLED) { + return "`jf` is not installed (or not on PATH)"; + } + return "`jf` has no configured server"; +} + // Prose fragment for the pending-notice {{CAUSE_REMEDIATION}} placeholder. function causeRemediation(cause) { - if (cause === "jf-not-installed") { + if (cause === IdentityCause.JF_NOT_INSTALLED) { return ( "Begin by installing the JFrog CLI (`jf`) and adding it to PATH, then " + "configure a JFrog server by following the login flow in the base " + @@ -52,6 +63,24 @@ function causeRemediation(cause) { ); } +// Numbered steps for {{CAUSE_CHECKLIST}}. When jf is already present, omit the +// "Confirm jf is installed" step so it does not contradict remediation. +function causeChecklist(cause) { + const configure = + "Configure a JFrog server (login flow or `jf config add` with access token);\n" + + " confirm with `jf config show`."; + const setup = + "Invoke **`jfrog-setup-package-managers`** to bind package managers this workspace needs."; + if (cause === IdentityCause.JF_NOT_INSTALLED) { + return ( + "1. Confirm `jf` is installed (`jf --version`).\n" + + `2. ${configure}\n` + + `3. ${setup}` + ); + } + return `1. ${configure}\n2. ${setup}`; +} + function jfrogPlatformUrlHint() { const raw = process.env.JFROG_PLATFORM_URL?.trim(); if (!raw) { @@ -87,7 +116,7 @@ function rewriteBulletFor(type, resolved) { const r = resolved[type]; if (!r) { return ( - `- \`${type}\` — **unresolved** (no Artifactory repo for this PM yet). ` + + `- \`${type}\` — **unresolved** (no Artifactory repo for this package manager yet). ` + `Per hard rule #5, do not invent a URL: invoke \`jfrog-setup-package-managers\` ` + `for \`${type}\` BEFORE any direct command. Once the binding is recorded, ` + `route subsequent \`${type}\` commands through the resolved URL yourself.` @@ -96,18 +125,28 @@ function rewriteBulletFor(type, resolved) { const url = r.baseUrl; switch (type) { case "npm": - return `- \`npm install \` → \`npm install --registry ${url}\``; + return ( + `- \`npm install \` → \`npm install --registry ${url}\`\n` + + `- \`pnpm add \` / \`pnpm install\` → \`pnpm add --registry ${url}\`` + ); case "pypi": return ( `- \`pip install \` → \`pip install --index-url ${url}\`\n` + - `- \`poetry add \` → first \`poetry source add jfrog ${url} --priority=primary\`` + `- \`pipenv install \` → \`pipenv install --pypi-mirror ${url}\`\n` + + `- \`uv add \` → \`UV_DEFAULT_INDEX=${url} uv add \` (or \`uv add --default-index ${url} \`)\n` + + `- \`uv pip install \` → \`uv pip install --index-url ${url}\`` ); case "go": return `- \`go get \` → \`GOPROXY=${url},direct go get \``; case "docker": - return `- \`docker pull [/]acme/app:1.2\` → \`docker pull ${url}/acme/app:1.2\` (drop a leading PUBLIC registry host — \`docker.io\`, \`ghcr.io\`, \`quay.io\`, \`gcr.io\`, …. Leave \`localhost\`/\`127.0.0.1\`, private/internal registries, and the JFrog host itself as-is; if unsure, resolve the host — a private/loopback IP means internal, leave it)`; + return ( + `- \`docker pull [/]acme/app:1.2\` → \`docker pull ${url}/acme/app:1.2\` (drop a leading PUBLIC registry host — \`docker.io\`, \`ghcr.io\`, \`quay.io\`, \`gcr.io\`, …. Leave \`localhost\`/\`127.0.0.1\`, private/internal registries, and the JFrog host itself as-is; if unsure, resolve the host — a private/loopback IP means internal, leave it)\n` + + `- \`podman pull …\` → same prefix rules as docker against \`${url}\`` + ); case "maven": - return `- \`mvn ...\` / \`gradle ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; + return `- \`mvn ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; + case "gradle": + return `- \`gradle ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; case "helm": return `- \`helm ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; case "nuget": @@ -144,25 +183,26 @@ function buildDockerSection(governed, resolved) { return "\n## Docker (before any `docker pull`)\n\n" + body + "\n"; } -// Pending-mode scope line — the governed PMs are known from config alone (no -// network / no resolution needed). Notes that matching PMs will be +// Pending-mode scope line — the governed package managers are known from config +// alone (no network / no resolution needed). Notes that matching package +// managers will be // auto-configured once routing is ready. Does NOT claim any type is routed yet. function buildPendingGovernedScope() { const governed = globalDeclaredTypes(); if (!governed.length) { return ( "No package managers are declared for routing yet (`defaultGlobalRepos` is empty). " + - "Ask an admin which PMs to govern." + "Ask an admin which package managers to govern." ); } return ( `**Governed package managers (once ready):** ${governed.join(", ")}. ` + - "Package managers not listed are out of scope. Matching PMs may be auto-configured " + + "Package managers not listed are out of scope. Matching package managers may be auto-configured " + "via `jf setup` once a JFrog server is configured; nothing is routed until then." ); } -// "This policy governs only: …" scope line so the agent knows which PMs are in +// "This policy governs only: …" scope line so the agent knows which package managers are in // scope and treats everything else as hands-off. function buildGovernedScope(governed) { if (!governed.length) { @@ -198,10 +238,15 @@ export async function renderInstruction(flag, ctx = {}) { path.join(TEMPLATES_DIR, PENDING_TEMPLATE), "utf8", ); + notice = notice.replace(/\{\{CAUSE_INTRO\}\}/g, causeIntro(flag.cause)); notice = notice.replace( /\{\{CAUSE_REMEDIATION\}\}/g, causeRemediation(flag.cause), ); + notice = notice.replace( + /\{\{CAUSE_CHECKLIST\}\}/g, + causeChecklist(flag.cause), + ); notice = notice.replace( /\{\{JFROG_PLATFORM_URL_HINT\}\}/g, jfrogPlatformUrlHint(), diff --git a/plugins/jfrog/modules/package-resolution/scripts/repo-types.mjs b/plugins/jfrog/modules/package-resolution/scripts/repo-types.mjs index 6b0a7de..1c34282 100644 --- a/plugins/jfrog/modules/package-resolution/scripts/repo-types.mjs +++ b/plugins/jfrog/modules/package-resolution/scripts/repo-types.mjs @@ -1,11 +1,21 @@ // Package-type constants shared by resolver and workspace overlay. -export const PACKAGE_TYPES = ["npm", "pypi", "maven", "go", "docker", "helm", "nuget"]; +export const PACKAGE_TYPES = [ + "npm", + "pypi", + "maven", + "gradle", + "go", + "docker", + "helm", + "nuget", +]; const TYPE_PACKAGE_TYPE = { npm: "npm", pypi: "pypi", maven: "maven", + gradle: "gradle", go: "go", docker: "docker", helm: "helm", diff --git a/plugins/jfrog/modules/package-resolution/scripts/resolver.mjs b/plugins/jfrog/modules/package-resolution/scripts/resolver.mjs index 763ed5d..94ab701 100644 --- a/plugins/jfrog/modules/package-resolution/scripts/resolver.mjs +++ b/plugins/jfrog/modules/package-resolution/scripts/resolver.mjs @@ -77,6 +77,7 @@ function urlFor(type, repoKey, base) { case "pypi": return `${base}/api/pypi/${repoKey}/simple/`; case "maven": + case "gradle": return `${base}/${repoKey}/`; case "go": return `${base}/api/go/${repoKey}`; diff --git a/plugins/jfrog/modules/package-resolution/templates/package-resolution-unconfigured.md b/plugins/jfrog/modules/package-resolution/templates/package-resolution-unconfigured.md index 604e5b6..edc459a 100644 --- a/plugins/jfrog/modules/package-resolution/templates/package-resolution-unconfigured.md +++ b/plugins/jfrog/modules/package-resolution/templates/package-resolution-unconfigured.md @@ -1,7 +1,7 @@ # Package Resolution — JFrog Artifactory routing is NOT READY Your organization routes every package fetch through JFrog Artifactory, but routing -cannot run yet — `jf` has no configured server. Package managers still point at +cannot run yet — {{CAUSE_INTRO}}. Package managers still point at **public** registries until setup completes. {{GOVERNED_SCOPE}} @@ -9,6 +9,7 @@ cannot run yet — `jf` has no configured server. Package managers still point a **Do not run direct package installs for the governed package managers** while routing is not ready. When asked to install or pull governed packages, tell the user routing is blocked and complete setup first. Package managers not listed above are out of scope — handle them normally. +Do not delegate governed installs to `agent -p` or another child while routing is not ready; refuse before launching and tell the user the **JFrog Artifactory Package Resolution policy** blocks delegation until routing is ready. A child prompt cannot replace trusted Package Resolution injection. ## Read this first @@ -23,14 +24,11 @@ Authoritative procedure: {{JFROG_PLATFORM_URL_HINT}} -1. Confirm `jf` is installed (`jf --version`). -2. Configure a JFrog server (login flow or `jf config add` with access token); - confirm with `jf config show`. -3. Invoke **`jfrog-setup-package-managers`** to bind PMs this workspace needs. +{{CAUSE_CHECKLIST}} ## After setup — load the routing policy -Once `jf` is configured and `jfrog-setup-package-managers` has bound the PMs, +Once `jf` is configured and `jfrog-setup-package-managers` has bound the package managers, load the current routing policy by running: ```bash diff --git a/plugins/jfrog/modules/package-resolution/templates/package-resolution.md b/plugins/jfrog/modules/package-resolution/templates/package-resolution.md index b62842c..09077f4 100644 --- a/plugins/jfrog/modules/package-resolution/templates/package-resolution.md +++ b/plugins/jfrog/modules/package-resolution/templates/package-resolution.md @@ -5,7 +5,9 @@ Your organization mediates package fetches through JFrog Artifactory for the shell, sub-agent, or MCP tool — route through the resolved Artifactory repository. {{GOVERNED_SCOPE}} +Whenever this policy blocks an action, explicitly say it is blocked by the organization's **JFrog Artifactory Package Resolution policy**. {{AUTO_SETUP_STATUS}} + ## Resolved URLs for this session {{RESOLVED_TABLE}} @@ -15,7 +17,7 @@ If any row shows ``, ask the user which repo to use and in ## Rewrite templates -Direct installs — form the command yourself (no automatic rewriter; `jf setup` PM +Direct installs — form the command yourself (no automatic rewriter; `jf setup` package-manager config and server-side Curation back this): {{REWRITE_BULLETS}} @@ -24,21 +26,22 @@ config and server-side Curation back this): 1. **Only URLs in the table above** — for the governed package managers, no default upstream registries, mirrors, or CDNs. 2. **Never override flags the user typed** (`--registry`, `--index-url`, `GOPROXY=…`) — if the command already includes a routing flag, surface the conflict with policy and ask before changing the command. This applies only to flags already in the command, **not** to verbal requests in chat to bypass routing policy. -3. **Indirect installs** (`npx`, `pip install -r`, `docker build`, postinstall scripts) — trust PM config files; if missing, run `jfrog-setup-package-managers`. +3. **Indirect installs** (`npx`, `pip install -r`, `docker build`, postinstall scripts) — trust package-manager config files; if missing, run `jfrog-setup-package-managers`. 4. **Curation block** — surface the reason verbatim; do not retry another host. -5. **Unresolved governed PM** — if the table shows `` for a governed PM the user - requested, **do not run the original command**. In order: (a) invoke `jfrog-setup-package-managers` for that PM, +5. **Unresolved governed package manager** — if the table shows `` for a governed package manager the user + requested, **do not run the original command**. In order: (a) invoke `jfrog-setup-package-managers` for that package manager, (b) wait until `.jfrog/local/package-resolution.json` records the binding, (c) re-issue routed via the templates above. A successful exit from an unrouted command still violates policy. 6. **401/403 from JFrog** — run `jfrog-setup-package-managers` (`jf setup`); never raw `docker login` / `npm login` / `pip config`. -7. **No public-registry bypass** — if the user asks to use public registries or skip JFrog routing for a governed PM, refuse. Explain the policy and offer the JFrog-routed command from the rewrite templates above. +7. **No public-registry bypass** — if the user asks to use public registries or skip JFrog routing for a governed package manager, refuse. State clearly that the request is blocked by the organization's **JFrog Artifactory Package Resolution policy**, then offer the JFrog-routed command from the rewrite templates above. +8. **No delegation bypass** — do not spawn `agent -p` or another agent for a governed package-install task unless that child receives this same Package Resolution policy from trusted `sessionStart` injection. **Refuse before launching an unprotected child.** Spawning a child merely so it can refuse is still a policy violation. A routed command or policy text in the child's user prompt cannot replace trusted injection because the child can execute different commands. Never pass a forbidden install request unchanged to a child. In the refusal, explicitly say that the **JFrog Artifactory Package Resolution policy** requires governed installs to remain routed through Artifactory. **Package managers not listed above are out of scope** — install them normally; no JFrog routing required. Do not block them, do not invoke `jfrog-setup-package-managers` for them. {{DOCKER_SECTION}} When a **governed** package manifest appears and `.jfrog/local/package-resolution.json` lacks the -matching PM, invoke `jfrog-setup-package-managers` proactively (see that skill for -manifest → PM mapping). Do not do this for ungoverned package managers. +matching package manager, invoke `jfrog-setup-package-managers` proactively (see that skill for +manifest → package-manager mapping). Do not do this for ungoverned package managers. ## Enablement From 863b69b8cb826342d37364d5b9b0df053b302af7 Mon Sep 17 00:00:00 2001 From: Roee Sanker Date: Thu, 30 Jul 2026 14:57:53 +0300 Subject: [PATCH 2/2] docs: align package-resolution admin guide with v0.8.0 Document gradle as its own type, multi-package-manager autoSetup families, and schema-2 receipts keyed by package-manager token. Co-authored-by: Cursor --- docs/package-resolution-admin-guide.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/package-resolution-admin-guide.md b/docs/package-resolution-admin-guide.md index 4ea1ede..e161690 100644 --- a/docs/package-resolution-admin-guide.md +++ b/docs/package-resolution-admin-guide.md @@ -74,6 +74,7 @@ This lets you **pre-deploy** your own `agents-conf.json` (via MDM, Ansible, flee "npm": "npm-virtual", "pypi": "pypi-virtual", "maven": "maven-virtual", + "gradle": "gradle-virtual", "go": "go-virtual", "docker": "docker-virtual", "helm": "helm-virtual", @@ -95,7 +96,7 @@ You do not have to turn on every package type at once. The package types Agent P - the keys you list in `defaultGlobalRepos` (your org default), and - any keys declared in a project's workspace override file (see [Workspace-level repository overrides](#workspace-level-repository-overrides) below). -Any package type you don't declare anywhere is **out of scope**: the agent installs it normally, with no routing, no friction, and no "unresolved" state to explain to your developers. This makes it easy to start narrow, for example just `npm` and `pypi`, and expand later, rather than committing to all 7 types on day one. See the "npm and PyPI only" example under [Configuration examples](#configuration-examples) below. +Any package type you don't declare anywhere is **out of scope**: the agent installs it normally, with no routing, no friction, and no "unresolved" state to explain to your developers. This makes it easy to start narrow, for example just `npm` and `pypi`, and expand later, rather than committing to all 8 types on day one. See the "npm and PyPI only" example under [Configuration examples](#configuration-examples) below. --- @@ -115,6 +116,7 @@ If you're enabling this for more than a handful of users, use your standard endp | ------------------------------------------------------ | -------------------------------------------------------------------------------------- | | Enable Agent Package Resolution for the targeted users | Set `"packageResolution": { "enabled": true, ... }` in the deployed file | | Map to your Artifactory repos | Edit `defaultGlobalRepos` with your real repo keys | +| Auto-configure package managers at session start | Add types to `autoSetup` ([Zero-touch setup](#zero-touch-setup-autosetup)) — each type expands to its package-manager family | | Force a refresh of the cached repo snapshot | Set `"cacheTtlDays": 0` (re-resolves repos every session; does **not** force autoSetup re-runs), or edit `agents-conf.json` (cache invalidates on file change) | | Support troubleshooting | Set `"logLevel": "debug"` temporarily; logs go to `~/.jfrog/logs/agent-hooks.log` | @@ -156,9 +158,10 @@ By default, package-manager binding (`jf setup`) happens when the developer or a - `autoSetup` takes a list of package type names, or `true` to mean "all governed types." - Only types that are both **governed** (declared in `defaultGlobalRepos` or a workspace override) and **resolved** are eligible; other names are ignored with a warning in the log. +- For each eligible type, the plugin runs `jf setup` for **every package manager in that type's family** that the installed CLI supports and that is present on PATH (for example `pypi` → pip, pipenv, uv; `npm` → npm, pnpm). Missing binaries are skipped with a warning (no failed receipt) and listed in the zero-touch note. `pip` requires `pip3`/`pip` on PATH. `maven` and `gradle` are separate governed types and are not PATH-gated — `jf setup` only writes config files (wrapper-only projects still get setup). - It only runs in `routing` mode (a working `jf` identity). Nothing is auto-configured in `pending` mode. - It's off by default (`[]`) and safe to leave off: without it, setup still happens, just triggered by the developer's or agent's first use of that package type instead of automatically. -- It's idempotent. Each result is recorded in `~/.jfrog/skills-cache/package-setup.json`, keyed by server and package type, and trusted for `cacheTtlDays`. A repo that fails to configure (for example, a missing repo key or no permission) is deferred rather than retried every session. It retries when the TTL expires, or immediately if you change the repo key or JFrog server URL. +- It's idempotent. Each result is recorded in `~/.jfrog/skills-cache/package-setup.json` (schema `2`, keyed by server and **package-manager token**, for example `pip` / `uv`), and trusted for `cacheTtlDays`. A package manager that fails to configure (for example, a missing repo key or no permission) is deferred rather than retried every session. It retries when the TTL expires, or immediately if you change the repo key or JFrog server URL. Prior schema receipts are discarded on read; the next session re-fills package-manager keys via idempotent `jf setup`. --- @@ -186,7 +189,7 @@ All keys are optional. Unknown keys are ignored. | `autoSetup` | `[]` | Governed types to auto-configure with `jf setup` at session start, or `true` for all. See Zero-touch setup below | -**Supported package types:** `npm`, `pypi`, `maven`, `go`, `docker`, `helm`, `nuget`. +**Supported package types:** `npm`, `pypi`, `maven`, `gradle`, `go`, `docker`, `helm`, `nuget`. --- @@ -213,6 +216,7 @@ Package types not listed in `defaultGlobalRepos` (and not declared in a workspac "npm": "corp-npm-virtual", "pypi": "corp-pypi-virtual", "maven": "corp-maven-virtual", + "gradle": "corp-gradle-virtual", "go": "corp-go-virtual", "docker": "art-docker", "helm": "corp-helm-local", @@ -226,7 +230,7 @@ Deploy this file to `~/.jfrog/agents-conf.json` on users' machines. The change t ### npm and PyPI only (minimal preview rollout) -A good way to start a preview without committing to all 7 package types at once: +A good way to start a preview without committing to all 8 package types at once: ```json { @@ -314,21 +318,22 @@ Workspace values win over `agents-conf.json` for matching package types during t | Wrong repository URLs | Verify `defaultGlobalRepos` keys exist on your Platform; check `verifyRepos` and `~/.jfrog/skills-cache/package-resolution.json` | | Invalid config ignored | Malformed JSON logs a **WARN** in `~/.jfrog/logs/agent-hooks.log` and falls back to safe defaults (`enabled: false`) | | Reset to shipped defaults | Delete `~/.jfrog/agents-conf.json`; it is recopied automatically. Optionally delete `package-resolution.json` and `package-setup.json` from the cache to clear snapshots and setup receipts | -| `autoSetup` type not configured | Confirm the type is governed and the session was in `routing` mode. Check `agent-hooks.log` and `package-setup.json` in the skills cache | +| `autoSetup` type not configured | Confirm the type is **governed + resolved** and the session was in `routing` mode. Check `agent-hooks.log` and `package-setup.json` (per package-manager entries such as `pip` / `uv`) | +| Re-run an eager `jf setup` | Change the repo key (or server), delete that package manager's entry in `~/.jfrog/skills-cache/package-setup.json` (or the whole file), or wait for `cacheTtlDays` to expire | --- ## What this preview covers -Agent Package Resolution runs at the start of every coding-agent session. When enabled, it injects routing policy and resolved Artifactory repository URLs into the session, so the agent prefers your repositories over public registries (npm, PyPI, Maven, Go, Docker, Helm, NuGet) for the rest of that session. +Agent Package Resolution runs at the start of every coding-agent session. When enabled, it injects routing policy and resolved Artifactory repository URLs into the session, so the agent prefers your repositories over public registries (npm, PyPI, Maven, Gradle, Go, Docker, Helm, NuGet) for the rest of that session. **About this preview (please read before rolling out):** - **This is advisory steering, not a hard block.** The feature tells the agent which repository to use and nudges it to configure package managers accordingly. It does not intercept or rewrite the underlying install commands. If you need a hard guarantee that nothing reaches a public registry, that guarantee comes from the two mechanisms below, not from this session-injection layer alone. - **Durable enforcement is `jf setup` (package manager configuration) plus server-side Curation.** Once a package manager is bound to your Artifactory repository (via `jf setup`, which the agent will run for you when needed), that binding persists across sessions and tools, independent of this feature. Curation policies on the server are what actually block disallowed packages. -- **All 7 package types are configurable** (npm, PyPI, Maven, Go, Docker, Helm, NuGet), but you do not have to turn them all on at once. A narrower starting scope (for example, just npm and PyPI) is a reasonable way to begin a preview rollout; see the configuration examples above. Package types you don't declare are left completely alone, see [Selective governance](#selective-governance-choose-which-package-types-to-route). -- **Package-manager binding can happen automatically** if you turn on `autoSetup` for a package type, instead of waiting for a developer's or agent's first use to trigger it. See [Zero-touch setup](#zero-touch-setup-autosetup). +- **All 8 package types are configurable** (npm, PyPI, Maven, Gradle, Go, Docker, Helm, NuGet), but you do not have to turn them all on at once. A narrower starting scope (for example, just npm and PyPI) is a reasonable way to begin a preview rollout; see the configuration examples above. Package types you don't declare are left completely alone, see [Selective governance](#selective-governance-choose-which-package-types-to-route). +- **Package-manager binding can happen automatically** if you turn on `autoSetup` for a package type: the plugin expands each type to its package-manager family (for example `pypi` → pip/pipenv/uv) instead of waiting for a developer's or agent's first use. See [Zero-touch setup](#zero-touch-setup-autosetup). - This is a **preview**. Expect rough edges, and please route feedback through the channel below. ---