Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "jfrog",
"displayName": "JFrog",
"description": "Official JFrog plugin. Connect Claude Code to JFrog to manage, secure, and govern your software supply chain. Give agents the context to build secure, compliant software.",
"version": "0.2.16",
"version": "0.2.17",
"author": {
"name": "JFrog Ltd.",
"email": "devrel@jfrog.com",
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/sync-modules-vendor.json
Original file line number Diff line number Diff line change
@@ -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"
]
Expand Down
21 changes: 13 additions & 8 deletions docs/package-resolution-admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.

---

Expand All @@ -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` |

Expand Down Expand Up @@ -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`.

---

Expand Down Expand Up @@ -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`.

---

Expand All @@ -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",
Expand All @@ -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
{
Expand Down Expand Up @@ -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.

---
Expand Down
1 change: 1 addition & 0 deletions modules/assets/agents-default-conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 42 additions & 13 deletions modules/core/io.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}

Expand Down
36 changes: 22 additions & 14 deletions modules/core/jf-identity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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}`);
Expand Down
Loading
Loading