diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 92357cc..f62a7d4 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.15", + "version": "0.5.16", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 4ca704b..fa0b57e 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.8.1", + "pin": "jfrog-agent-hooks/v0.11.0", "paths": [ "modules" ] diff --git a/README.md b/README.md index 4e7fe8b..dedab47 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ JFrog plugin for [Cursor](https://cursor.com): artifact management, security sca ## What's new -- **Agent Package Resolution (Preview).** A new opt-in hook automatically routes the packages your AI agent installs through your JFrog Artifactory instead of public registries. See [Agent Package Resolution](#agent-package-resolution-preview). +- **Agent Package Resolution (Preview).** Routes packages your AI agent installs through your JFrog Artifactory instead of public registries (enabled by default in the shipped template; bind repos via Consent Enable or admin config). See [Agent Package Resolution](#agent-package-resolution-preview). - **AI Catalog skill.** New `jfrog-ai-catalog-skills` skill to discover, install, update, and publish agent skills hosted in the JFrog AI Catalog. --- @@ -69,7 +69,7 @@ Run `jf login` for browser-based setup, or set the `JFROG_ACCESS_TOKEN` environm The plugin can now automatically route the packages your AI agent installs (npm, PyPI, Maven, Go, Docker, Helm, and NuGet) through your organization's JFrog Artifactory instead of public registries. This keeps agent-driven dependency installs inside your organization's governance perimeter. -Agent Package Resolution is in preview and opt-in. To get started: +Agent Package Resolution is in preview. The shipped template enables it with empty repository bindings (nothing is routed until Consent Enable or an admin adds `defaultGlobalRepos`). To get started: - **Users:** see the [User Guide](docs/package-resolution-user-guide.md). - **Admins:** see the [Admin Guide](docs/package-resolution-admin-guide.md). diff --git a/docs/package-resolution-admin-guide.md b/docs/package-resolution-admin-guide.md index 4e4dce2..b4f32b7 100644 --- a/docs/package-resolution-admin-guide.md +++ b/docs/package-resolution-admin-guide.md @@ -1,48 +1,61 @@ -# Agent Package Resolution: Admin Guide (Preview) +# Configure Agent Package Resolution for Coding Agents -> **Preview Notice:** This feature is in preview and licensed under the Apache License 2.0. For clarity: This software is provided "as-is" without warranty of any kind, and without support obligations or service level commitments. Behavior, APIs, conventions, and structure may change without notice between releases. JFrog makes no guarantees of backward compatibility during the preview release cycle. Use in production environments is at your own risk. +Route AI-assisted package installs through your JFrog Artifactory repositories when developers use the **JFrog plugin** for Cursor, Claude Code, or VS Code. -**Audience:** Artifactory administrators / DevOps teams rolling this out to users. +Agent Package Resolution runs at the start of each agent session. When enabled, it injects routing policy and resolved Artifactory URLs into the session so the agent prefers your repositories over public registries. Durable enforcement still comes from **package manager configuration** (`jf setup`) and **JFrog Curation** on the server. -Thank you for testing **Agent Package Resolution**, a feature in the JFrog plugin that routes AI coding-agent package installs (Cursor, Claude Code) through your organization's Artifactory instead of public registries, automatically and without relying on the person prompting the agent to know or follow your org's package-routing practices. That matters more now that coding agents let people beyond traditional developers write code and pull dependencies too. This guide covers everything an admin needs to turn it on and configure it for your organization during this preview. +This guide is for **platform administrators** and **developers** onboarding the JFrog coding-agent plugins. For installing the plugin itself, see the JFrog documentation for your IDE ([Cursor](https://docs.jfrog.com/ai-ml/docs/cursor), [Claude Code](https://docs.jfrog.com/ai-ml/docs/claude-code/), [VS Code](https://docs.jfrog.com/ai-ml/docs/vs-code)). + +> **Related:** [Use the MCP Registry with Agent Guard](https://docs.jfrog.com/ai-ml/docs/configure-coding-agents) covers MCP governance. Agent Package Resolution is a separate capability in the same JFrog plugin family and uses the same local configuration file for admin settings. --- -## Prerequisites +## Setup summary -The only hard prerequisites are the plugin and a JFrog Platform account for each user. Everything else below adapts automatically per user, rather than being something you need to arrange in advance: +| Step | Action | +| ---- | ---------------------------------------------------------------------------------------------------------- | +| 1 | Install the JFrog plugin in your coding assistant | +| 2 | Install and configure the JFrog CLI (`jf config add`) — required for **routing** mode | +| 3 | Confirm `~/.jfrog/agents-conf.json` (shipped template enables APR with empty bindings; or deploy your own) | +| 4 | Start a **new agent session** — policy and URLs are injected once per session | -- **The JFrog plugin installed** in your users' coding assistant (Cursor and/or Claude Code), whether users install it themselves or your org distributes it centrally. -- **A JFrog Platform account for each user.** Whether their local environment is already set up to use it is not something you need to arrange; the feature detects and handles that itself, see [Operating modes](#operating-modes) below. +The shipped template turns Agent Package Resolution **on** (`enabled: true`) with empty `defaultGlobalRepos`. Nothing is routed until Consent Enable or an administrator adds bindings. ---- +**At a glance:** -## Setup summary +- **Default:** on, but routes nothing until you add repositories. +- **To route installs:** add repository keys under `defaultGlobalRepos` (org config or Consent Enable). +- **To turn it off org-wide:** deploy your own `agents-conf.json` with `"enabled": false` (see [Turning Agent Package Resolution off](#turning-agent-package-resolution-off-admins)). Setting `"enabled": false` on the plugin's **default file without also deploying your own** is not durable — the plugin re-enables it on the next session. -Only two things are actually required from you as the admin: +--- +## Prerequisites -| Step | Action | -| ---- | ------------------------------------------------------------------------------------------------------------------------- | -| 1 | Confirm the JFrog plugin is installed in your users' coding assistant | -| 2 | Enable Agent Package Resolution and set default repositories in `~/.jfrog/agents-conf.json`, for whichever users you want | +- **JFrog Platform access** with Artifactory repositories for the package types you use (npm, PyPI, Maven, Go, Docker, Helm, NuGet). The developer environment must be able to reach your JFrog Platform URL — Agent Package Resolution resolves routing from live platform identity and repository metadata. +- **JFrog plugin** installed for your coding assistant. +- **JFrog CLI (`jf`) configured** with `jf config add` (or equivalent). Platform identity for **routing** mode comes **only** from `jf config` (server URL + access token **or** username + password / API key stored by the CLI). +### Identity and environment variables -Everything else adapts on its own, per user, without you needing to arrange it (see [Prerequisites](#prerequisites) above). Once a user's `agents-conf.json` is enabled or changed, it takes effect automatically; there is no need to start a new agent session to pick up the change. +| Variable / source | Used by Agent Package Resolution? | Purpose | +| ------------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `jf config` (CLI server) | **Yes — required for routing mode** | URL + token used to resolve repos and run eager `jf setup` | +| `JFROG_PLATFORM_URL` | **Hint only** | Optional. When set in the IDE launch environment, the “routing not ready” notice can show this hostname so the developer knows which platform to configure. It does **not** authenticate or activate routing by itself | +| `JFROG_URL` | **No** | Not read by Agent Package Resolution (may be used by other JFrog products / Agent Guard docs — do not rely on it here) | +| `JFROG_ACCESS_TOKEN` | **No** | Not read by Agent Package Resolution. Setting a token in the environment does **not** put the hook into routing mode | -Agent Package Resolution is **opt-in**, and enablement is per user, not global by default. You decide which users to enable it for: deploy or edit `agents-conf.json` centrally (MDM, golden image) for all or a chosen subset of machines, or have specific users edit their own file locally. For a preview, enabling it for a handful of users directly is often simpler than a full rollout. See "Configuration file" below. +If `jf` is missing or has no usable configured server, the feature stays in **pending** mode (advisory “routing not ready” notice) even when `packageResolution.enabled` is `true`. --- ## Configuration file: `~/.jfrog/agents-conf.json` -All Agent Package Resolution admin settings live in a single JSON file on each user's machine: +All Agent Package Resolution admin settings live in a single JSON file on the developer machine: ``` ~/.jfrog/agents-conf.json ``` - | Property | Description | | --------------------- | -------------------------------------------------------------------------------- | | **Scope** | Per user profile (`$HOME`) | @@ -50,118 +63,190 @@ All Agent Package Resolution admin settings live in a single JSON file on each u | **Read by** | JFrog plugin session hooks on every agent session start | | **Never overwritten** | If the file already exists, the plugin does not replace it | - ### First session behavior -When a user opens their first agent session after installing the plugin: +When a developer opens their first agent session after installing the plugin: + +1. If `~/.jfrog/agents-conf.json` **does not exist**, the plugin copies the **shipped default template** into that path. +2. The template ships with Agent Package Resolution **enabled** (`packageResolution.enabled: true`), empty `defaultGlobalRepos`, and `onboardingPrompt: "auto"`. Never-configured legacy scaffolds (`enabled: false` that still match a shipped fingerprint) are migrated to `enabled: true` on SessionStart (hand-edited / MDM configs and `onboardingPrompt: "off"` are left alone). +3. When the offer gate is open (`onboardingPrompt: "auto"` or an untouched scaffold fingerprint) **and** at least one APR package type is missing from `defaultGlobalRepos` and not declined in `~/.jfrog/skills-cache/apr-onboarding-v1.json`, SessionStart writes a short **onboarding offer rule**. Cursor: `~/.cursor/rules/jfrog-apr-onboarding.mdc` (`alwaysApply: true`). Claude Code: `~/.claude/rules/jfrog-apr-onboarding.md`. **VS Code Copilot does not get this Yes/No offer** (policy inject + admin / `configure.mjs` only). Cursor↔Claude rule writes are all-or-nothing — one failure rolls back both. The rule asks only when install intent hits an unbound, undeclined type — not on every unrelated chat. Rules stay while any type remains offerable (including after a partial `enable`); they are deleted when nothing is left to offer or the global gate closes. `onboardingPrompt: "off"` stays fully silent. +4. **No** for one type runs `dismiss --type ` (per-type decline cache). Bare `dismiss` sets `onboardingPrompt: "off"` (global silence). **Yes** runs Consent Enable / `enable` for binding; it does **not** force global `onboardingPrompt: "off"`. +5. Routing policy is injected when `packageResolution.enabled` is `true` **and** `jf` identity is usable (`routing`); otherwise `pending` when enabled but `jf` is missing — including when `defaultGlobalRepos` is still empty. + +This lets organizations **pre-deploy** their own `agents-conf.json` (via MDM, Ansible, fleet policy, etc.) **before** developers run the plugin. A pre-deployed file is never clobbered. The `onboardingPrompt` field is the **global** offer gate: + +| `onboardingPrompt` | Behavior | +| ------------------ | ------------------------------------------------------------------------------------------------------ | +| `"off"` | Never offer — global silence (bare `dismiss`, or admin). Also **blocks** scaffold migration that would flip `enabled: false` → `true` | +| `"auto"` | Keep the relevance-gated offer while any type is still offerable | +| absent | Offer only when the file still matches a shipped scaffold fingerprint; a hand-edited file stays silent | + +Per-type durable declines live in `~/.jfrog/skills-cache/apr-onboarding-v1.json` (not in `agents-conf.json`). + +`onboardingPrompt: "off"` alone does **not** turn APR off when `enabled` is still `true` — it only silences offers (and protects scaffolds from re-enable migration). + +### Consent Enable (developer chat flow) + +When the nudge fires, the agent walks the developer through enabling APR **in chat** (no hand-editing JSON): -1. If `~/.jfrog/agents-conf.json` **does not exist**, the plugin copies the shipped default template into that path. -2. The template ships with Agent Package Resolution **disabled** (`packageResolution.enabled: false`). -3. No routing policy is injected until an administrator or user sets `packageResolution.enabled` to `true`. +1. Confirm `jf` is installed and has a usable server. +2. Ask **which package types** to govern (free text; default is not “all”). +3. Configure **one type at a time**. For each type, ask for an Artifactory **project key** or **repository** key/name (either is enough). Resolve with the base **`jfrog` skill** only through a bounded path — never list the catalog, all virtuals, or wildcards (`*-virtual`, `**`): + - Repository given → `configure.mjs verify-repo` on that key only (ignore a project if also given). + - Project given, no repository → one filtered call: that project + `type=virtual` + this `packageType`. 0 → ask again; 1 → bind; 2–10 → show name+key and ask; more than 10 → discard the payload and ask for the exact repository name. + - Neither given → point-lookup `-virtual`, `-default`, then `-release`. 0 hits → ask again; 1 → bind; 2–3 → ask among those keys only. + - Query/auth errors are not “none found” — fix and retry the same bounded call. If the type never binds, leave it off and say so (suggest contacting an Artifactory admin). Verify every key with `configure.mjs verify-repo`. There is no discovery skill and no `configure.mjs discover`. +4. Enable and turn on zero-touch `autoSetup` for the bound types (no second auto-setup ask). `enable` **replaces** `defaultGlobalRepos` (it does not merge) — re-include already-bound types. `auto-setup` **replaces** `autoSetup` the same way. `enable` re-verifies keys fail-closed and **re-syncs** offer rules (they stay while other types remain offerable): + ```bash + node /modules/package-resolution/scripts/configure.mjs enable --repos '{"pypi":"pypi-virtual","go":"go-virtual"}' + node /modules/package-resolution/scripts/configure.mjs auto-setup --types '["pypi","go"]' + ``` +5. Load the in-session routing table and wait for setup: `JFROG_EAGER_SETUP_SYNC=1 node …/print-policy.mjs`. That stdout is the Package Resolution table for this chat. Do not install while the note says `setting up in the background`. Types that show as already set up must use the normal package-manager command (**no** `--registry` / `--index-url` / `GOPROXY=…`). Report pending/failed/conflict types as not ready; do not claim overall success unless every bound type set up. +6. Suggest starting a **new chat/session** so SessionStart injects the full routing table into context. -This lets you **pre-deploy** your own `agents-conf.json` (via MDM, Ansible, fleet policy, etc.) **before** users ever run the plugin. A pre-deployed file is never clobbered. +Other `configure.mjs` commands: `status [--json]`, `onboarding-procedure` (prints the full Consent Enable steps — the offer rule only carries the short ask and points here), `verify-repo --type --repo `, `dismiss --type ` (per-type decline in `apr-onboarding-v1.json`), and bare `dismiss` (global silence via `onboardingPrompt: "off"`). ### Shipped default template +The plugin bundles a read-only template equivalent to: + ```json { "logLevel": "info", "packageResolution": { - "enabled": false, + "enabled": true, "verifyRepos": true, "cacheTtlDays": 7, - "defaultGlobalRepos": { - "npm": "npm-virtual", - "pypi": "pypi-virtual", - "maven": "maven-virtual", - "gradle": "gradle-virtual", - "go": "go-virtual", - "docker": "docker-virtual", - "helm": "helm-virtual", - "nuget": "nuget-virtual" - }, + "onboardingPrompt": "auto", + "defaultGlobalRepos": {}, "autoSetup": [] } } ``` -Replace the repository keys with the **repo keys that actually exist on your JFrog Platform**. The names above are examples only. To govern only some package types, list only those types in `defaultGlobalRepos`, see [Selective governance](#selective-governance-choose-which-package-types-to-route) below. +The empty map means no package types are governed yet — installs are not +rewritten to Artifactory until you add bindings. With `enabled: true`, SessionStart +can still inject a pending-mode advisory until `jf` is usable, and soft-bridge +offer rules may ask to bind types on install intent. Add only the package types +and repository keys that exist on your JFrog Platform (via Consent Enable with +the `jfrog` skill + `verify-repo`, or manually — see +[Selective governance](#selective-governance-choose-which-package-types-to-route)). +With default `verifyRepos: true`, Consent Enable / `configure.mjs enable` accepts +keys Artifactory confirms as virtual repositories of the requested package type. ---- +### Turning Agent Package Resolution off (admins) -## Selective governance: choose which package types to route +> **Why `enabled: false` alone may not stick.** Because the feature now ships **on**, the plugin re-enables its **own default file** if it finds it still turned off. "Default file" means the `agents-conf.json` the plugin auto-created and that no one has changed except (at most) the `enabled` flag. As soon as you deploy your **own** config, or add any other setting (like `onboardingPrompt`), the plugin treats it as yours and never re-enables it. -You do not have to turn on every package type at once. The package types Agent Package Resolution actually routes for a session, the **governed** types, are the union of: +**Pick the option that matches how you manage machines:** -- 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). +| Your situation | Do this | Result | +| -------------- | ------- | ------ | +| You push config with MDM / a golden image | Deploy your own `agents-conf.json` with `"enabled": false` | Durable off — your file is never overwritten or re-enabled | +| You only edited the plugin's auto-created file | Set **both** `"enabled": false` **and** `"onboardingPrompt": "off"` | Durable off — `onboardingPrompt` marks the file as yours, so it is not re-enabled | +| You need an immediate, per-machine kill switch (CI, break-glass) | Set env var `JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1` | Off for that process, even if the file says `enabled: true` | -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. +**Recommended config for a durable off (works in every case):** + +```json +{ + "packageResolution": { + "enabled": false, + "onboardingPrompt": "off" + } +} +``` + +Setting **only** `"onboardingPrompt": "off"` stops the Consent Enable prompts but does **not** turn the feature off — leave `enabled: false` in place for that. See also [emergency disable](#environment-variable-emergency-disable) for the environment variable. --- -## Rolling this out to multiple users at once +## Admin control: deploy `agents-conf.json` across your organization -If you're enabling this for more than a handful of users, use your standard endpoint management to push a consistent `agents-conf.json`, rather than editing each one by hand. This applies equally to a small preview group or a full org-wide rollout, it's the same file either way, just targeted at whichever machines you choose. +Use standard endpoint management to place a consistent `agents-conf.json` on every developer machine. **Typical rollout pattern:** -1. Build a golden `agents-conf.json` for the users you're targeting (see the examples below). -2. Deploy it to `~/.jfrog/agents-conf.json` on their machines with your MDM or configuration management tool. That's it, the change takes effect automatically, no per-user auth check and no session restart needed (see [Prerequisites](#prerequisites)). +1. Build a golden `agents-conf.json` for your org (see [examples](#configuration-examples) below). +2. Deploy to `~/.jfrog/agents-conf.json` with your MDM or configuration management tool. +3. Ensure developers have a configured `jf` CLI (`jf config add`). +4. Ask developers to **start a new chat/session** after deployment (hooks run once per session). **Tips for administrators** - -| Goal | Approach | -| ------------------------------------------------------ | -------------------------------------------------------------------------------------- | -| 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` | - +| Goal | Approach | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Enable Agent Package Resolution org-wide | Set `"packageResolution": { "enabled": true, ... }` in the deployed file | +| Keep APR **off** (durable) | Deploy your own file with `"enabled": false`, **or** set `"enabled": false` **and** `"onboardingPrompt": "off"` on the plugin's default file — see [Turning off](#turning-agent-package-resolution-off-admins) | +| Silence Consent Enable offers only | Set `"onboardingPrompt": "off"` (does not disable APR while `enabled` is `true`) | +| Map to your Artifactory repos | Edit `defaultGlobalRepos` with your real repo keys | +| Govern only some package types | List only those types in `defaultGlobalRepos` — others stay out of scope ([Selective governance](#selective-governance-choose-which-package-types-to-route)) | +| Auto-configure package managers at first session | Add types to `autoSetup` ([Zero-touch setup](#zero-touch-setup-autosetup)) | +| Force all cached state to refresh | Set `"cacheTtlDays": 0` (this also re-runs eligible zero-touch `jf setup` each session), or edit `agents-conf.json` | +| Support troubleshooting | Set `"logLevel": "debug"` temporarily; logs go to `~/.jfrog/logs/agent-hooks.log` | +| Break-glass / process off | `JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1` ([emergency disable](#environment-variable-emergency-disable)) | --- ## Operating modes -Once enablement is resolved, Agent Package Resolution runs in one of three modes each session: +After enablement is resolved, Agent Package Resolution runs in one of three modes each session: +| Mode | When | What the developer sees | +| ----------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **off** | `packageResolution.enabled` is not `true`, or disable env var is set | Nothing — no Agent Package Resolution injection | +| **pending** | Enabled, but `jf` is missing or not configured | Advisory notice: routing is not ready, with setup steps and the governed package types | +| **routing** | Enabled and `jf` is installed with a usable configured server | Full routing policy for the **governed** package types + resolved Artifactory URLs; optional zero-touch `jf setup` for types in `autoSetup` | -| Mode | When | What the user sees | -| ----------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| **off** | `packageResolution.enabled` is not `true`, or the disable environment variable is set | Nothing; no Agent Package Resolution injection | -| **pending** | Enabled, but `jf` is missing or not configured | An advisory notice: routing is not ready, with setup steps | -| **routing** | Enabled and `jf` is configured and usable | Full routing policy with resolved Artifactory URLs | +`pending` steers the agent and the developer toward setup (no governed installs +until `jf` is ready). Kernel-level blocks still come from Curation and durable +package-manager config; the injected **Decision order** is what the agent must +follow in every session. +In `routing` mode the injected policy covers **only the governed package types** (see [Selective governance](#selective-governance-choose-which-package-types-to-route)); package managers you do not govern are left untouched. If `autoSetup` lists governed **and resolved** types, the plugin also runs `jf setup` for them in the background so their durable PM config is ready without manual steps (see [Zero-touch setup](#zero-touch-setup-autosetup)). -`pending` is **advisory**: it steers the agent and user toward setup. It is not a hard block. Hard enforcement comes from Curation and bound package manager configuration. +### Agent decision flow (routing mode) ---- +Canonical matrix (terms, mermaid, per-case table, closed fallbacks): +[apr-agent-decision-flow.md](../design/apr-agent-decision-flow.md). -## Zero-touch setup: `autoSetup` +**Do not conflate these three signals:** -By default, package-manager binding (`jf setup`) happens when the developer or agent runs it, typically via a one-time confirmation the first time a project uses a given package type. `autoSetup` lets you skip that confirmation: the plugin runs `jf setup` **automatically, in the background, at session start** for whichever governed package types you list. Binding usually finishes before the developer asks for anything, including indirect installs like a postinstall script, `pip install -r`, or `npx`. Because setup is asynchronous, a very early first install in the same session can still race it. +| Signal | Meaning | Written by | +| --- | --- | --- | +| Resolved URL in the session table | Knows *where* to route | Hook resolver | +| Workspace binding (`.jfrog/local/package-resolution.json`) | Project recorded the repo decision | Setup skill after `jf setup` — **not** autoSetup | +| Durable PM config (`~/.npmrc`, …) | Tool-native routing for indirect installs | `jf setup` via autoSetup **or** the setup skill | -```json -{ - "packageResolution": { - "enabled": true, - "defaultGlobalRepos": { - "npm": "npm-virtual", - "pypi": "pypi-virtual" - }, - "autoSetup": ["pypi"] - } -} -``` +**Decision order (first match wins)** — mirrored in the injected template: -- `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-v2.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. The v2 ledger is a dedicated file so older plugin builds that still write schema-1 `package-setup.json` cannot thrash it; on first run after upgrade the v2 file is empty and idempotent `jf setup` re-fills it once. If another `jf setup` worker already holds the lock, pending jobs are deferred to the next session. +If the user asks to use a public registry or skip JFrog for a governed PM, apply step 7 **immediately**. + +1. Unresolved table row → setup skill; never invent a URL. +2. Zero-touch status line: `already set up` → normal command (trust PM config; **no** `--registry` / `--index-url` / `GOPROXY=…`); `setting up in the background` → **direct rewrite only** (no indirect until `already set up`). +3. Foreign-host conflict on the zero-touch status line → ask before `jf setup `. +4. Governed manifest present **and** workspace binding missing that type → setup skill **first**, then install (no rewrite-flag-only shortcut; Agent Guard bootstrap exempt). This is issue #91. +5. Binding present **or** no governed manifest → flag-based rewrite/trust; config-driven (maven/gradle/helm/nuget) unbound → setup skill first. +6. 401/403 → setup skill again; never raw `npm login` / etc. +7. Public-registry / skip-JFrog ask → refuse; offer the next allowed Decision step. + +### Agent hard rules (routing mode) + +The injected `package-resolution.md` template includes hard rules the agent must follow for **governed** types only (in addition to the Decision order): + +| Rule | Behavior | +| --- | --- | +| Artifactory URLs only | Route governed installs through the resolved URL table — no public registries, mirrors, or CDNs | +| CLI flags vs. chat | If the user's **command** already includes a routing flag (`--registry`, `--index-url`, `GOPROXY=…`), surface the conflict and ask before changing it. Verbal requests in chat to skip JFrog routing do **not** override policy | +| Indirect installs | Trust PM config; if missing, run the setup skill (unless zero-touch lists that PM as `already set up`) | +| Curation block | Surface the server reason verbatim; do not retry another host | +| Unresolved PM | Decision step 1 — do not run the original command; invoke setup first | +| 401/403 | Decision step 6 — setup skill; never raw `docker login` / `npm login` / `pip config` | +| No public bypass | Refuse; offer the **next allowed Decision step** (not a rewrite that step 4 forbids) | +| No delegation bypass | Refuse launching a child agent unless it receives trusted `sessionStart` injection of this policy | +| Agent Guard bootstrap | Exception to Decision step 4 **and** hard rule #7: installing `@jfrog/agent-guard` alone may keep that package's specified registry even when a governed manifest is unbound | +| Docker | Rewrite bare and public-host `docker pull` refs with the resolved JFrog docker row; leave `localhost` and private/internal hosts unchanged | +| Manifest unbound | Decision step 4 — durable `jf setup` + workspace binding before treating the install as done when a **governed** manifest is present and autoSetup did not already handle the PM | --- @@ -171,35 +256,118 @@ All keys are optional. Unknown keys are ignored. ### Top level - | Key | Default | Description | | ---------- | ------- | --------------------------------------------------------------------------------------------------------- | | `logLevel` | `info` | Hook log verbosity: `silent`, `debug`, `info`, `warn`, `error`. Log file: `~/.jfrog/logs/agent-hooks.log` | - ### `packageResolution` +| Key | Default | Description | +| -------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | Shipped scaffold default. `true` turns Agent Package Resolution on for the user (subject to a usable `jf` config and the disable env var). Empty `defaultGlobalRepos` means no governed routing yet. | +| `verifyRepos` | `true` | When `true`, each repo key in `defaultGlobalRepos` is verified against Artifactory before use | +| `cacheTtlDays` | `7` | Days to reuse a per-server result before re-checking. Governs **both** the verified repo snapshot and eager `jf setup` receipt. `0` always re-checks; use it only when deliberately avoiding all cached state. | +| `defaultGlobalRepos` | See template | Map of package type → Artifactory **repository key**. Org-wide governed types. A verified workspace overlay can add further governed types for that checkout. `configure.mjs enable --repos` **replaces** this map (it does not merge) | +| `autoSetup` | `[]` | Governed types to auto-configure with `jf setup` at session start. Array of type names, or `true` for all governed types. `configure.mjs auto-setup --types` **replaces** this list. See [Zero-touch setup](#zero-touch-setup-autosetup) | -| Key | Default | Description | -| -------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `false` | `true` turns Agent Package Resolution on for the user (subject to `jf` / auth and the disable environment variable) | -| `verifyRepos` | `true` | When `true`, each repo key in `defaultGlobalRepos` is verified against Artifactory before use | -| `cacheTtlDays` | `7` | Days to reuse the verified repo snapshot **and** autoSetup receipts per JFrog server. For repo resolve, `0` re-resolves every session; for autoSetup receipts, `0` means no timer expiry (retry only on repo key / server URL change) | -| `defaultGlobalRepos` | See template above | Map of package type to Artifactory **repository key**. Keys also define which types are **governed**, see below | -| `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`, `gradle`, `go`, `docker`, `helm`, `nuget`. +**Governed vs. ungoverned.** A package type is **governed** when it is in +`defaultGlobalRepos` **or** a workspace overlay (`.jfrog/local/package-resolution.json`) +that actually resolved. Only governed types appear in the injected policy: -**Supported package types:** `npm`, `pypi`, `maven`, `gradle`, `go`, `docker`, `helm`, `nuget`. +- **Governed + resolved** — routed: a table row + rewrite rule, and (if listed in `autoSetup`) an eager `jf setup`. +- **Admin-declared + unresolved** (in `defaultGlobalRepos` but the repo is missing or fails verification) — shown as `` and blocked until setup, so a misconfiguration is never silently sent to a public registry. +- **Workspace-only + unresolved** — dropped: not governed, not blocked, not autoSetup-eligible. +- **Ungoverned** (not in `defaultGlobalRepos` and not a verified workspace key) — **out of scope**: omitted from the policy entirely and left for the agent to handle normally. This is how you route, for example, `pypi` without touching `docker`. + +### Repo resolution order (per package type) + +1. **Workspace overlay** — `.jfrog/local/package-resolution.json` in the project (if present) +2. **Cached snapshot** — `~/.jfrog/skills-cache/package-resolution.json` (per JFrog server, respects TTL) +3. **Admin defaults** — `defaultGlobalRepos` in `agents-conf.json` (on cache miss or stale cache) --- -Package types not listed in `defaultGlobalRepos` (and not declared in a workspace override) are **out of scope** for that session — the agent installs them normally, with no routing and no unresolved/setup flow, until you add the mapping. +## Selective governance: choose which package types to route -### Repo resolution order (per package type) +Agent Package Resolution governs **only the package types you declare**. This lets you onboard incrementally — start with, say, `pypi` and `npm`, and leave `docker`, `go`, and everything else untouched until you are ready. + +- **To govern a type org-wide**, add it to `defaultGlobalRepos`. +- **To govern a type in one checkout**, add a verified key in `.jfrog/local/package-resolution.json` (Consent Enable / the setup skill does this). +- **To leave a type alone**, don't declare it in either place. Ungoverned types never appear in the injected policy and the agent installs them normally, with no JFrog routing and no "unresolved" blocking. + +Example — govern only PyPI, leave Docker (and the rest) alone: -1. **Workspace overlay:** `.jfrog/local/package-resolution.json` in the project (if present). Lets a team override the org default for a specific repo checkout. -2. **Cached snapshot:** `~/.jfrog/skills-cache/package-resolution.json` (per JFrog server, respects `cacheTtlDays`). -3. **Admin defaults:** `defaultGlobalRepos` in `agents-conf.json` (on cache miss or stale cache). +```json +{ + "packageResolution": { + "enabled": true, + "defaultGlobalRepos": { + "pypi": "corp-pypi-virtual" + } + } +} +``` + +A workspace can override an administrator-approved repository key, or add a +type that is not in `defaultGlobalRepos`. Overlay keys are verified when +`verifyRepos` is enabled: + +```json +{ + "repositories": { + "pypi": "team-pypi-virtual" + } +} +``` + +With the two files above, that project still governs only `pypi`, but resolves it +through `team-pypi-virtual`. Adding another verified key in the workspace file +(for example `"npm": "team-npm-virtual"`) would also govern `npm` in that +checkout only. + +--- + +## Zero-touch setup: `autoSetup` + +Without `autoSetup`, the injected Decision order still applies: when a governed +project manifest is present and there is no workspace binding, the agent must +run `jfrog-setup-package-managers` (durable `jf setup`) **before** treating a +direct install as done — a rewrite-flag install alone is not enough. With +`autoSetup`, the plugin performs that `jf setup` **automatically at session +start** for the types you choose (and the session note marks them as already +set up / setting up), so a developer's first session already resolves indirect +installs (`npx`, `pip install -r`, postinstall scripts) through Artifactory +without forcing the skill again. + +```json +{ + "packageResolution": { + "enabled": true, + "defaultGlobalRepos": { + "npm": "corp-npm-virtual", + "pypi": "corp-pypi-virtual" + }, + "autoSetup": ["pypi"] + } +} +``` + +- `autoSetup` is a **list of type names**, or `true` to mean "all governed types". +- It is **repo-agnostic**: setup targets whatever repo actually resolves for that type this session (a workspace override wins over the org default). +- Only types that are **governed and resolved** are eligible. Names that aren't governed are ignored (logged as a warning). +- For each eligible type, the plugin runs `jf setup` for **every client tool in that type's family** that the installed CLI supports and that is present on PATH (e.g. `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 (`jf setup pip` runs `pip config set`). `maven` and `gradle` are separate governed types and are not PATH-gated — `jf setup` only writes `~/.m2/settings.xml` / a Gradle init script (wrapper-only projects still get config). On Windows, PATH lookup also honors `PATHEXT` (`.cmd`, `.exe`, …). +- `jf setup` mutates **user-global** PM config (`~/.npmrc`, `~/.docker/config.json`, …). It runs **off the critical path** in a background worker, so the session's instructions are still injected immediately — the 7-second session-start budget is never at risk. +- Runs are **idempotent**: a receipt at `~/.jfrog/skills-cache/package-setup-v2.json` (schema `2`, keyed by server + **package-manager token**, e.g. `pip` / `uv`) records each result — success **or** failure — and it is trusted for `cacheTtlDays`. A re-run is triggered by a changed repo key, a different server, or an expired TTL; a fresh result (within the TTL) is skipped. The v2 file is separate from legacy `package-setup.json` (schema 1) so older plugin builds cannot thrash the ledger; first run after upgrade starts empty and re-fills via idempotent `jf setup`. +- `jf setup` validates the repo itself; a bad repo or missing permission is recorded as a **failure** for that PM and, crucially, is **not** retried every session — it is deferred until the `cacheTtlDays` window elapses (self-heals if you create/fix the repo server-side) or retried immediately when you correct the repo key or switch servers. The failure is surfaced in the next session's note, and advisory routing always still applies. +- **Foreign-host conflict:** when an existing PM config already points at a **different** Artifactory (or public registry) host, zero-touch **skips** that package manager — it is left unchanged (no silent overwrite). The session note lists each skipped tool with `existingHost → targetHost` and instructs the agent to ask _"Switch to this JFrog instance?"_ before running explicit `jf setup ` (with `--server-id` / `--repo` as needed) **only** for the tools the user approves — not bare `jf setup`. Explicit `jf setup` from the user or skill can still overwrite after confirmation. + +**Prerequisite:** eager setup only runs in `routing` mode (a configured `jf` server). In `pending` mode nothing is auto-configured; once `jf` is configured, running the refresh command (`node /modules/package-resolution/scripts/print-policy.mjs`) triggers eager setup exactly as a fresh session would — no restart needed. + +During **Consent Enable**, the agent sets `autoSetup` for the types just configured via `configure.mjs auto-setup --types '[…]'` (no separate second ask), then runs `JFROG_EAGER_SETUP_SYNC=1 node …/print-policy.mjs` so setup finishes in that turn. Types that show as already set up must later install without rewrite flags. Admins can also pre-deploy `autoSetup` in `agents-conf.json` as shown above. + +--- ## Configuration examples @@ -226,11 +394,9 @@ Package types not listed in `defaultGlobalRepos` (and not declared in a workspac } ``` -Deploy this file to `~/.jfrog/agents-conf.json` on users' machines. The change takes effect automatically, no restart needed. - -### npm and PyPI only (minimal preview rollout) +Deploy this file to `~/.jfrog/agents-conf.json` on developer machines, then have users start a **new agent session**. -A good way to start a preview without committing to all 8 package types at once: +### npm and Docker only (minimal rollout) ```json { @@ -238,13 +404,13 @@ A good way to start a preview without committing to all 8 package types at once: "enabled": true, "defaultGlobalRepos": { "npm": "npm-virtual", - "pypi": "pypi-virtual" + "docker": "docker-virtual" } } } ``` -Other package types stay out of scope until you expand the map. +Only `npm` and `docker` are governed here. All other package types are **out of scope** — the agent installs them normally with no JFrog routing until you add them to the map. See [Selective governance](#selective-governance-choose-which-package-types-to-route). ### Debug logging for support @@ -257,27 +423,27 @@ Other package types stay out of scope until you expand the map. } ``` -Inspect `~/.jfrog/logs/agent-hooks.log` on the user's machine. Return to `"logLevel": "info"` after troubleshooting. +Inspect `~/.jfrog/logs/agent-hooks.log` on the developer machine. Return to `"logLevel": "info"` after troubleshooting. --- -## Emergency disable - -You can force Agent Package Resolution **off** for a process without editing `agents-conf.json`. Useful for CI images, break-glass support, or a temporary rollback during the preview. +## Environment variable: emergency disable +Organizations can force Agent Package Resolution **off** for a process without editing `agents-conf.json`. This is useful for CI images, break-glass support, or temporary rollback. | Variable | Value | Effect | | ------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | | `JF_AGENT_PACKAGE_RESOLUTION_DISABLE` | `1` | Agent Package Resolution stays **off** for that IDE/terminal process, even if `agents-conf.json` has `enabled: true` | - **Precedence (enablement):** 1. `JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1` → **off** 2. `packageResolution.enabled: true` in `agents-conf.json` → **on** (if `jf` / auth allows) -3. Otherwise → **off** (shipped default) +3. Otherwise → **off** (explicit `enabled: false`, or a hand-edited file that is not the shipped scaffold) -To set it: +### macOS / Linux (Zsh or Bash) + +Add to the IDE launch environment or shell profile: ```bash export JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1 @@ -285,13 +451,27 @@ export JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1 Restart the coding assistant after changing environment variables. -> Removing the variable (or setting it to anything other than `1`) restores file-based enablement from `agents-conf.json`. +### Windows (PowerShell — user scope) + +```powershell +[Environment]::SetEnvironmentVariable("JF_AGENT_PACKAGE_RESOLUTION_DISABLE", "1", "User") +``` + +Restart the IDE completely so it inherits the new value. + +> **Note:** Removing the variable (or setting it to anything other than `1`) restores file-based enablement from `agents-conf.json`. + +### Optional: platform URL hint (`JFROG_PLATFORM_URL`) + +When `jf` is missing or unconfigured, the hook injects a “routing not ready” notice. If `JFROG_PLATFORM_URL` is set in the **IDE launch environment**, that value is included in the notice as a setup hint (which hostname to use with `jf config add`). + +This variable is **not** a substitute for `jf config`. It does not supply credentials and does not move the session into **routing** mode. `JFROG_ACCESS_TOKEN` and `JFROG_URL` are likewise **not** used for Agent Package Resolution identity. --- ## Workspace-level repository overrides -Users (or project templates) can override your org-wide defaults for a specific repository checkout: useful for mono-repos or team-specific repo keys, without touching the org-wide `agents-conf.json`. +Developers (or project templates) can override global defaults for a specific repository checkout: **File:** `/.jfrog/local/package-resolution.json` @@ -304,48 +484,31 @@ Users (or project templates) can override your org-wide defaults for a specific } ``` -Workspace values win over `agents-conf.json` for matching package types during that session. +Workspace values win over `agents-conf.json` for matching types during that session. Use this for mono-repo or team-specific repo keys without changing the org-wide `agents-conf.json`. --- ## Troubleshooting - -| Symptom | What to check | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| No routing policy in the agent | `packageResolution.enabled` is `true` in `~/.jfrog/agents-conf.json` | -| Policy still off despite enabled config | `JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1` is set in the IDE environment | -| 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-v2.json` from the cache to clear snapshots and setup receipts | -| `autoSetup` type not configured | Confirm the type is **governed + resolved** and the session was in `routing` mode. Check `agent-hooks.log` and `package-setup-v2.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-v2.json` (or the whole file), or wait for `cacheTtlDays` to expire | - +| Symptom | What to check | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No routing policy in the agent | `packageResolution.enabled` is `true` in `~/.jfrog/agents-conf.json`, then run `node /modules/package-resolution/scripts/print-policy.mjs` to load the policy | +| “Routing not ready” notice | Install and configure `jf` (`jf config add`). Env vars alone (`JFROG_ACCESS_TOKEN`, `JFROG_URL`) will **not** clear this. Optional: set `JFROG_PLATFORM_URL` so the notice shows your platform hostname. After configuring `jf`, run the notice's refresh command (`node /modules/package-resolution/scripts/print-policy.mjs`) or start a new session | +| Policy still off despite enabled config | `JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1` in the IDE environment | +| 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 the shipped template defaults (`enabled: true`, empty bindings) | +| A governed type isn't in the policy | Confirm it's in `defaultGlobalRepos` or a verified workspace overlay (`.jfrog/local/package-resolution.json`). Admin-declared types that fail verify stay in the policy as unresolved; workspace-only keys that fail verify are dropped. Ungoverned types are intentionally omitted | +| `autoSetup` type not auto-configured | Must be **governed + resolved** and in `routing` mode; check `~/.jfrog/logs/agent-hooks.log` for the `jf setup` result and `~/.jfrog/skills-cache/package-setup-v2.json` for the recorded status. If another session holds the setup lock, the note says setup is deferred until the next session | +| Re-run an eager `jf setup` | Change the repo key (or server), delete the PM's entry (e.g. `pip`, `uv`) in `~/.jfrog/skills-cache/package-setup-v2.json` (or the whole file), or wait for `cacheTtlDays` to expire | +| A bad repo keeps retrying every session | Fixed in current behavior — a failed `jf setup` is deferred for `cacheTtlDays` instead of retried each session. Correct the repo key to retry immediately, or fix the repo/permission in Artifactory (it self-heals after the TTL) | +| Reset to shipped defaults | Delete `~/.jfrog/agents-conf.json` and start a new session (template is recopied). Optionally delete `~/.jfrog/skills-cache/package-resolution.json` and `~/.jfrog/skills-cache/package-setup-v2.json` to clear cached snapshots + setup receipts | --- -## 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, 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 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. - ---- - -## Feedback and support - -This is a preview. Please report issues, confusion, or surprises. - -File an issue on GitHub, in whichever plugin repo you use: - -- Cursor: [github.com/jfrog/cursor-plugin/issues](https://github.com/jfrog/cursor-plugin/issues) -- Claude Code: [github.com/jfrog/claude-plugin/issues](https://github.com/jfrog/claude-plugin/issues) -- Email: plugins-feedback@jfrog.com +## Related documentation -We're especially interested in: whether the enablement steps above were clear, whether routing behaved as expected once enabled, and anything that felt broken or surprising during rollout. \ No newline at end of file +- [JFrog Plugins overview](https://docs.jfrog.com/ai-ml/docs/jfrog-plugins) +- [Install JFrog Plugin for Cursor](https://docs.jfrog.com/ai-ml/docs/install-jfrog-plugin-for-cursor) +- [Install JFrog Plugin for Claude Code](https://docs.jfrog.com/ai-ml/docs/install-jfrog-plugin-for-claude-code) +- [Install JFrog Plugin for VS Code](https://docs.jfrog.com/ai-ml/docs/install-jfrog-plugin-for-vs-code) +- [Use the MCP Registry with Agent Guard](https://docs.jfrog.com/ai-ml/docs/configure-coding-agents) diff --git a/docs/package-resolution-user-guide.md b/docs/package-resolution-user-guide.md index 9dfbf2c..a2109f2 100644 --- a/docs/package-resolution-user-guide.md +++ b/docs/package-resolution-user-guide.md @@ -44,9 +44,9 @@ This is the state you'll be in almost all the time. ## Turning it on -Agent Package Resolution is disabled by default; it needs to be enabled for any of the above to take effect. It can be turned on by your org, or by you directly. +The shipped template turns Agent Package Resolution **on** (`enabled: true`) with empty repository bindings. Nothing is routed to Artifactory until your org (or Consent Enable in chat) adds keys under `defaultGlobalRepos`. -Edit `~/.jfrog/agents-conf.json` (created automatically the first time you use the plugin, so the file already exists) and set: +To bind package types yourself, edit `~/.jfrog/agents-conf.json` (created automatically the first time you use the plugin) and set repository keys that exist on your JFrog Platform: ```json { @@ -60,7 +60,7 @@ Edit `~/.jfrog/agents-conf.json` (created automatically the first time you use t } ``` -If a repository key isn't accurate for your org, update it to the correct one. If you don't know the correct key, or a key doesn't exist on your JFrog Platform, that package type simply stays unrouted until someone corrects it; nothing breaks. The change takes effect automatically, no need to start a new session. +If a repository key isn't accurate for your org, update it to the correct one. If you don't know the correct key, or a key doesn't exist on your JFrog Platform, that package type simply stays unrouted until someone corrects it; nothing breaks. Start a **new agent session** after changing the file so SessionStart reloads policy. ## Turning it off @@ -72,6 +72,8 @@ export JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1 Restart your IDE for it to take effect. This overrides `agents-conf.json`, so it works even if your org has enabled the feature centrally. Remove the variable (or restart without it set) to turn routing back on. Please also report the issue (see [Feedback](#feedback)) so we can fix it. +To turn it off in the config file itself, set `"enabled": false`. If your file is still the untouched shipped scaffold, also set `"onboardingPrompt": "off"` — otherwise the next session can migrate `enabled` back to `true`. Setting only `"onboardingPrompt": "off"` silences Consent Enable offers; it does **not** disable APR while `enabled` remains `true`. + --- ## Good to know (doesn't require you to do anything) @@ -86,7 +88,7 @@ Restart your IDE for it to take effect. This overrides `agents-conf.json`, so it | Symptom | What to do | |---------|-------------| | Install fails with `401` / `403` even though routing looked ready | Your token is expired or revoked, not a repository problem; this isn't caught until an install actually fails. Log in again for that server | -| Nothing seems to be happening / no mention of Artifactory | It's off by default until someone turns it on; see [Turning it on](#turning-it-on) above to turn it on yourself, or check with your admin | +| Nothing seems to be happening / no mention of Artifactory | Confirm `enabled` is `true` and `defaultGlobalRepos` has the package type; see [Turning it on](#turning-it-on), or check with your admin. Pending mode (no usable `jf` config) only shows a setup advisory | | Install used the wrong repository | Check whether your project has a `.jfrog/local/package-resolution.json` override, or ask your admin what the org default is for that package type. See [Advanced](#advanced-project-specific-repository-overrides) below | | You want to temporarily turn this off | See [Turning it off](#turning-it-off) above | | Something looks broken | Check `~/.jfrog/logs/agent-hooks.log` for details, and let us know (see below); this is exactly the kind of thing we want to hear about during the preview | diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index f7cf10c..84ba9a8 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.15", + "version": "0.5.16", "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/README.md b/plugins/jfrog/README.md index ea300c0..e8ac892 100644 --- a/plugins/jfrog/README.md +++ b/plugins/jfrog/README.md @@ -41,7 +41,7 @@ The **jfrog-package-safety-and-download** skill (`skills/jfrog-package-safety-an The plugin can automatically route the packages your AI agent installs (npm, PyPI, Maven, Go, Docker, Helm, and NuGet) through your organization's JFrog Artifactory instead of public registries. This keeps agent-driven dependency installs inside your organization's governance perimeter. -Agent Package Resolution is in preview and opt-in. To get started: +Agent Package Resolution is in preview. The shipped template enables it with empty repository bindings (nothing is routed until Consent Enable or an admin adds `defaultGlobalRepos`). To get started: - **Users:** see the [User Guide](https://github.com/jfrog/cursor-plugin/blob/main/docs/package-resolution-user-guide.md). - **Admins:** see the [Admin Guide](https://github.com/jfrog/cursor-plugin/blob/main/docs/package-resolution-admin-guide.md). diff --git a/plugins/jfrog/modules/assets/agents-conf-fingerprints.json b/plugins/jfrog/modules/assets/agents-conf-fingerprints.json new file mode 100644 index 0000000..59a9182 --- /dev/null +++ b/plugins/jfrog/modules/assets/agents-conf-fingerprints.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "fingerprints": [ + { + "id": "v0-placeholders-no-onboardingPrompt", + "sha256": "452b737ede2af5da3ea660cb0a2226d422b5624fa1684bc883279422c0728421", + "note": "Legacy template with example repo keys, before onboardingPrompt" + }, + { + "id": "v1-placeholders-onboardingPrompt-auto", + "sha256": "b19251b4671db244a8050885bcbaf5f217f0e4eecfec34c0338264b08fa7c871", + "note": "Legacy template with example repo keys + onboardingPrompt: auto" + }, + { + "id": "v2-empty-defaultGlobalRepos", + "sha256": "5a104c83c4cb67f2cb01d71ad0044a438f9125bab868ef76e24bd7be7828b82b", + "note": "Empty defaultGlobalRepos after #84 (no onboardingPrompt)" + }, + { + "id": "v3-empty-onboardingPrompt-auto", + "sha256": "8b68d55af89e2dadf4ff0c3ae0784b70051c24d1fb75e3ea6df8ba3044c5cefa", + "note": "Legacy template: enabled false + empty defaultGlobalRepos + onboardingPrompt: auto" + }, + { + "id": "v4-enabled-onboardingPrompt-auto", + "sha256": "f0481d915f1f7f2a1e7d88ab23ce7b9430d3e44e41aaabb4d4d13e2b40963ae2", + "note": "Current shipped template: enabled true + empty defaultGlobalRepos + onboardingPrompt: auto" + } + ] +} diff --git a/plugins/jfrog/modules/assets/agents-default-conf.json b/plugins/jfrog/modules/assets/agents-default-conf.json index 3d13fce..35ceff5 100644 --- a/plugins/jfrog/modules/assets/agents-default-conf.json +++ b/plugins/jfrog/modules/assets/agents-default-conf.json @@ -1,19 +1,11 @@ { "logLevel": "info", "packageResolution": { - "enabled": false, + "enabled": true, "verifyRepos": true, "cacheTtlDays": 7, - "defaultGlobalRepos": { - "npm": "npm-virtual", - "pypi": "pypi-virtual", - "maven": "maven-virtual", - "gradle": "gradle-virtual", - "go": "go-virtual", - "docker": "docker-virtual", - "helm": "helm-virtual", - "nuget": "nuget-virtual" - }, + "onboardingPrompt": "auto", + "defaultGlobalRepos": {}, "autoSetup": [] } } diff --git a/plugins/jfrog/modules/claude-session-start.mjs b/plugins/jfrog/modules/claude-session-start.mjs index d7e688b..9a0f81b 100644 --- a/plugins/jfrog/modules/claude-session-start.mjs +++ b/plugins/jfrog/modules/claude-session-start.mjs @@ -9,8 +9,16 @@ import process from "node:process"; import { runCapability } from "./core/run-capability.mjs"; -import { ensureAgentsConfigScaffold, agentsConfigLoadWarnings } from "./core/agents-config.mjs"; -import { readStdin, parseSessionId, detectHarness, parseWorkspaceRoots } from "./core/io.mjs"; +import { + ensureAgentsConfigScaffold, + agentsConfigLoadWarnings, +} from "./core/agents-config.mjs"; +import { + readStdin, + parseSessionId, + detectHarness, + parseWorkspaceRoots, +} from "./core/io.mjs"; import { setLogContext, createLogger } from "./core/logger.mjs"; const HARNESS_ID = "claude_code"; diff --git a/plugins/jfrog/modules/copilot-session-start.mjs b/plugins/jfrog/modules/copilot-session-start.mjs new file mode 100644 index 0000000..9d0c275 --- /dev/null +++ b/plugins/jfrog/modules/copilot-session-start.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// GitHub Copilot Chat SessionStart hook runner (installed via the VS Code +// Copilot plugin — see jfrog/vscode-plugin). +// +// Usage: node copilot-session-start.mjs +// Example: node copilot-session-start.mjs package-resolution +// +// stdout: JSON with hookSpecificOutput.additionalContext. "{}" is a no-op. + +import process from "node:process"; + +import { runCapability } from "./core/run-capability.mjs"; +import { + ensureAgentsConfigScaffold, + agentsConfigLoadWarnings, +} from "./core/agents-config.mjs"; +import { + readStdin, + parseSessionId, + detectHarness, + parseWorkspaceRoots, +} from "./core/io.mjs"; +import { setLogContext, createLogger } from "./core/logger.mjs"; + +const HARNESS_ID = "copilot"; +const log = createLogger("session-start"); + +/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */ +function formatSessionStartStdout(text) { + if (!text?.trim()) return null; + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: text, + }, + }); +} + +function writeStdout(payload) { + if (payload === null) { + writeNoOp(); + return; + } + process.stdout.write(payload); +} + +function writeNoOp() { + process.stdout.write("{}"); +} + +async function main() { + const capability = process.argv[2]; + if (!capability) { + writeNoOp(); + return; + } + + const startedAtMs = Date.now(); + const stdinRaw = await readStdin(); + const harness = detectHarness(stdinRaw); + if (harness && harness !== HARNESS_ID) { + setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); + log.warn("harness mismatch; wrong adapter invoked", { + expected: HARNESS_ID, + detected: harness, + adapter: "copilot-session-start", + }); + writeNoOp(); + return; + } + const sessionId = parseSessionId(stdinRaw); + const workspaceRoots = parseWorkspaceRoots(stdinRaw); + setLogContext({ ide: HARNESS_ID, sessionId }); + ensureAgentsConfigScaffold(); + for (const w of agentsConfigLoadWarnings()) { + log.warn(w.message, { path: w.path }); + } + const text = await runCapability(capability, { + ide: HARNESS_ID, + sessionId, + workspaceRoots, + startedAtMs, + }); + writeStdout(formatSessionStartStdout(text)); +} + +main().catch(() => { + writeNoOp(); + process.exit(0); +}); diff --git a/plugins/jfrog/modules/core/agent-guard-check.mjs b/plugins/jfrog/modules/core/agent-guard-check.mjs new file mode 100644 index 0000000..8789667 --- /dev/null +++ b/plugins/jfrog/modules/core/agent-guard-check.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node +// JFrog Agent Guard activation check +// +// Silent gate for session hooks. Determines whether Agent Guard is enabled +// for the current environment. +// +// Contract (key off `code`, not `reason` text): +// - code 0 -> Agent Guard ENABLED (caller may proceed) +// - code 2 -> reachable but the platform has the MCP registry DISABLED +// - code 1 -> DISABLED for any other reason: no credentials, timeout, +// network/DNS error (caller must silently abort) +// +// Set JF_AGENT_GUARD_DEBUG=true for verbose tracing on stderr. +// Library callers use runAgentGuardCheck(); CLI entry calls process.exit. + +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +import { isMainEntry } from "./entry.mjs"; + +export const SETTINGS_PATH = + "/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled"; +export const REQUEST_TIMEOUT_MS = 5000; + +export const EXIT_ENABLED = 0; +export const EXIT_DISABLED = 1; +export const EXIT_REGISTRY_DISABLED = 2; + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {string} newName + * @param {string} [oldName] + * @returns {string | undefined} + */ +function envLookup(env, newName, oldName) { + const raw = env[newName] ?? (oldName ? env[oldName] : undefined); + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed || undefined; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {(message: string) => void} [debug] + */ +function makeDebug(env, debug) { + if (typeof debug === "function") return debug; + const enabled = env.JF_AGENT_GUARD_DEBUG === "true"; + return (message) => { + if (enabled) console.error(`[jfrog-agent-guard] ${message}`); + }; +} + +/** + * Resolve credentials from Path A (environment variables) or Path B + * (JFrog CLI configuration). + * + * Intentionally distinct from `jf-identity.mjs`: + * - package-resolution identity is always `jf config` and may use Basic auth; + * - Agent Guard's settings probe needs a Bearer access token, and mirrors the + * AG CLI by preferring JFROG_URL/JF_URL + access token when set. + * - When `serverId` is set: that jf server first, then env, never the default + * CLI server. Without `serverId`: env first, then default `jf config export`. + * Do not reuse getPlatformIdentity() here without preserving that contract. + * + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} [opts] + * @returns {{ baseUrl: string, token: string, source: string } | null} + */ +export function resolveAgentGuardCredentials(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + const explicitServerId = opts.serverId?.trim() || undefined; + const execFn = opts.execFileSyncFn ?? execFileSync; + + if (explicitServerId) { + const fromCli = resolveFromCliConfig({ + serverId: explicitServerId, + execFileSyncFn: execFn, + debug, + }); + if (fromCli) return fromCli; + debug( + "Explicit server ID did not resolve via jf config; falling back to env credentials.", + ); + } + + const envUrl = envLookup(env, "JFROG_URL", "JF_URL"); + const envToken = envLookup(env, "JFROG_ACCESS_TOKEN", "JF_ACCESS_TOKEN"); + if (envUrl && envToken) { + debug("Using credentials from environment variables (Path A)."); + return { + baseUrl: envUrl, + token: envToken, + source: "environment variables", + }; + } + debug( + "Environment credentials incomplete; trying JFrog CLI config (Path B).", + ); + + if (explicitServerId) return null; + return resolveFromCliConfig({ + serverId: undefined, + execFileSyncFn: execFn, + debug, + }); +} + +/** + * @param {{ + * serverId?: string, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} opts + */ +function resolveFromCliConfig(opts) { + const debug = opts.debug ?? (() => {}); + const execFn = opts.execFileSyncFn ?? execFileSync; + const exportArgs = opts.serverId + ? ["config", "export", opts.serverId] + : ["config", "export"]; + let exported; + try { + exported = execFn("jf", exportArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }).trim(); + } catch (error) { + debug( + `'jf config export' failed (jf not on PATH or no server configured): ${error?.message}`, + ); + return null; + } + + let cfg; + try { + cfg = JSON.parse(Buffer.from(exported, "base64").toString("utf8")); + } catch (error) { + debug(`Could not decode the jf config export token: ${error?.message}`); + return null; + } + + const baseUrl = cfg?.url; + const token = cfg?.accessToken; + if (!baseUrl) { + debug("Exported JFrog CLI config has no platform URL."); + return null; + } + if (!token) { + debug( + "Exported JFrog CLI config has no access token (bearer auth needed).", + ); + return null; + } + + const id = cfg?.serverId ?? "default"; + return { + baseUrl, + token, + source: `JF CLI config (server '${id}')`, + }; +} + +/** + * @param {string} baseUrl + * @param {string} token + * @param {{ + * fetchFn?: typeof fetch, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + */ +export async function isGatewayPluginEnabled(baseUrl, token, opts = {}) { + const debug = opts.debug ?? (() => {}); + const fetchFn = opts.fetchFn ?? fetch; + const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS; + + const root = baseUrl.replace(/\/+$/, "").replace(/\/artifactory$/, ""); + const url = root + SETTINGS_PATH; + debug(`Fetching gateway plugin setting from ${url}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchFn(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + }); + if (!response.ok) { + debug(`Settings request returned HTTP ${response.status}.`); + return { + ok: false, + reason: `settings endpoint returned HTTP ${response.status}`, + }; + } + const data = await response.json(); + const unwrap = (v) => (v !== null && typeof v === "object" ? v?.value : v); + const container = data?.settings ?? data; + const named = + container?.mcpGatewayPluginEnabled ?? + container?.mcp_gateway_plugin_enabled; + const value = + typeof data === "boolean" + ? data + : named !== undefined + ? unwrap(named) + : unwrap(container); + debug(`Settings response indicates gateway plugin enabled=${value}.`); + if (value === true) return { ok: true }; + if (value === false) { + return { + ok: false, + registryOff: true, + reason: "mcp gateway plugin setting returned false", + }; + } + return { + ok: false, + reason: "settings endpoint returned an invalid gateway-plugin setting", + }; + } catch (error) { + const reason = + error?.name === "AbortError" + ? "timeout" + : (error?.message ?? "unknown error"); + debug(`Settings request failed: ${reason}`); + return { + ok: false, + reason: `settings endpoint unreachable (${reason})`, + }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Run the Agent Guard activation check without exiting the process. + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * fetchFn?: typeof fetch, + * execFileSyncFn?: typeof execFileSync, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + * @returns {Promise<{ code: number, reason: string }>} + */ +export async function runAgentGuardCheck(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + + try { + const forceDisabled = + envLookup(env, "_JF_AGENT_GUARD_FORCE_DISABLE") === "true"; + const forceEnabled = + envLookup(env, "JF_AGENT_GUARD_FORCE_ENABLE") === "true"; + if (forceDisabled) { + return { + code: EXIT_DISABLED, + reason: "Disabled: forced via _JF_AGENT_GUARD_FORCE_DISABLE", + }; + } + if (forceEnabled) { + return { + code: EXIT_ENABLED, + reason: "Enabled: forced via JF_AGENT_GUARD_FORCE_ENABLE", + }; + } + + const creds = resolveAgentGuardCredentials({ + serverId: opts.serverId, + env, + execFileSyncFn: opts.execFileSyncFn, + debug, + }); + if (!creds) { + return { + code: EXIT_DISABLED, + reason: + "Disabled: JFROG_URL/JF_URL + access token not set and no default JF CLI config found", + }; + } + + const result = await isGatewayPluginEnabled(creds.baseUrl, creds.token, { + fetchFn: opts.fetchFn, + timeoutMs: opts.timeoutMs, + debug, + }); + if (result.ok) { + return { + code: EXIT_ENABLED, + reason: `Enabled: via ${creds.source}`, + }; + } + if (result.registryOff) { + return { + code: EXIT_REGISTRY_DISABLED, + reason: `RegistryDisabled: ${result.reason}`, + }; + } + return { + code: EXIT_DISABLED, + reason: `Disabled: ${result.reason}`, + }; + } catch (error) { + debug(`Unexpected error: ${error?.stack ?? error?.message ?? error}`); + return { code: EXIT_DISABLED, reason: "Disabled: unexpected error" }; + } +} + +async function main() { + const result = await runAgentGuardCheck({ + serverId: process.argv[2], + }); + process.stdout.write(`${result.reason}\n`); + process.exit(result.code); +} + +if (isMainEntry(import.meta.url)) { + main().catch((error) => { + console.error(`[jfrog-agent-guard] Unexpected error: ${error?.message}`); + process.exit(EXIT_DISABLED); + }); +} diff --git a/plugins/jfrog/modules/core/agents-config.mjs b/plugins/jfrog/modules/core/agents-config.mjs index 64a43df..288c290 100644 --- a/plugins/jfrog/modules/core/agents-config.mjs +++ b/plugins/jfrog/modules/core/agents-config.mjs @@ -4,15 +4,20 @@ // before capabilities run so first-time installs get a writable config file. import { - copyFileSync, + closeSync, existsSync, mkdirSync, + openSync, readFileSync, + renameSync, statSync, + unlinkSync, + writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isSafeRepoKey } from "../package-resolution/scripts/repo-types.mjs"; /** modules bundle root (parent of core/ and assets/). */ const PLUGIN_ROOT = path.resolve( @@ -28,8 +33,12 @@ const TEMPLATE_PATH = path.join( const DEFAULT_LOG_LEVEL = "info"; const DEFAULT_CACHE_TTL_DAYS = 7; +const AGENTS_CONFIG_LOCK_STALE_MS = 30_000; +const AGENTS_CONFIG_LOCK_WAIT_MS = 1_000; +const AGENTS_CONFIG_LOCK_POLL_MS = 25; let memoizedRaw = undefined; let memoizedForPath = null; +let memoizedMtimeMs = undefined; /** @type {{ source: 'missing' | 'user' | 'template', parseFailed: boolean, path: string }} */ let loadMeta = { source: "missing", parseFailed: false, path: "" }; @@ -37,29 +46,142 @@ function agentsConfigPath() { return path.join(homedir(), ".jfrog", "agents-conf.json"); } +function agentsConfigLockPath() { + return path.join(homedir(), ".jfrog", "agents-conf.lock"); +} + +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function tryAgentsConfigLock() { + mkdirSync(path.dirname(agentsConfigLockPath()), { recursive: true }); + const fd = openSync(agentsConfigLockPath(), "wx"); + try { + writeFileSync(fd, `${process.pid}\n${Date.now()}\n`); + } finally { + closeSync(fd); + } +} + +function releaseAgentsConfigLock() { + try { + unlinkSync(agentsConfigLockPath()); + } catch { + // ignore + } +} + +function reclaimStaleAgentsConfigLock(nowMs) { + const lock = agentsConfigLockPath(); + try { + const raw = readFileSync(lock, "utf8"); + const stampLine = raw.split("\n")[1]; + const ts = Number(stampLine); + const hasStamp = + typeof stampLine === "string" && + stampLine.trim() !== "" && + Number.isFinite(ts); + const ageMs = hasStamp ? nowMs - ts : nowMs - statSync(lock).mtimeMs; + if (ageMs > AGENTS_CONFIG_LOCK_STALE_MS) { + unlinkSync(lock); + return true; + } + } catch { + // ignore + } + return false; +} + +function acquireAgentsConfigLock(nowMs = Date.now()) { + try { + tryAgentsConfigLock(); + return true; + } catch { + if (!reclaimStaleAgentsConfigLock(nowMs)) return false; + try { + tryAgentsConfigLock(); + return true; + } catch { + return false; + } + } +} + +/** + * Serialize read-merge-rename of agents-conf.json across processes. + * Fails closed when the lock cannot be acquired — never silently races an + * unlocked RMW (Consent Enable / dismiss / SessionStart can overlap). + */ +function withAgentsConfigLock(fn) { + const deadline = Date.now() + AGENTS_CONFIG_LOCK_WAIT_MS; + let locked = acquireAgentsConfigLock(); + while (!locked && Date.now() < deadline) { + sleepSync(AGENTS_CONFIG_LOCK_POLL_MS); + locked = acquireAgentsConfigLock(Date.now()); + } + if (!locked) { + throw new Error( + "agents-conf.lock: could not acquire lock within wait budget", + ); + } + try { + return fn(); + } finally { + releaseAgentsConfigLock(); + } +} + function resetLoadMeta(configPath) { loadMeta = { source: "missing", parseFailed: false, path: configPath }; } /** - * Copy the shipped template to ~/.jfrog/agents-conf.json when missing. - * Never overwrites an existing file. + * Copy the shipped template when missing. Caller must hold agents-conf.lock + * (or use {@link ensureAgentsConfigScaffold}). Uses exclusive create so a + * late scaffold cannot clobber a concurrent patch that already created the file. */ -export function ensureAgentsConfigScaffold() { +function ensureAgentsConfigScaffoldUnlocked() { const configPath = agentsConfigPath(); if (existsSync(configPath)) return { created: false, path: configPath }; try { mkdirSync(path.dirname(configPath), { recursive: true }); - copyFileSync(TEMPLATE_PATH, configPath); + const fd = openSync(configPath, "wx"); + try { + writeFileSync(fd, readFileSync(TEMPLATE_PATH)); + } finally { + closeSync(fd); + } memoizedRaw = undefined; + memoizedForPath = null; + memoizedMtimeMs = undefined; return { created: true, path: configPath }; } catch { + // Another writer won the create race — treat as already present. + if (existsSync(configPath)) { + return { created: false, path: configPath }; + } return { created: false, path: configPath }; } } +/** + * Copy the shipped template to ~/.jfrog/agents-conf.json when missing. + * Never overwrites an existing file. Serialized with mergeAgentsConfigPatch. + */ +export function ensureAgentsConfigScaffold() { + return withAgentsConfigLock(() => ensureAgentsConfigScaffoldUnlocked()); +} + export { agentsConfigPath }; +/** Drop the in-process config memo (tests / direct writers that skip mergeAgentsConfigPatch). */ +export function invalidateAgentsConfigCache() { + memoizedRaw = undefined; + memoizedForPath = null; + memoizedMtimeMs = undefined; +} + /** @returns {number | null} mtime in ms, or null when the file is absent */ export function getAgentsConfigMtimeMs() { try { @@ -80,9 +202,15 @@ function parseAgentsJson(raw) { function readAgentsConfigRaw() { const configPath = agentsConfigPath(); - if (memoizedForPath !== configPath) { + const mtimeMs = getAgentsConfigMtimeMs(); + if ( + memoizedForPath !== configPath || + memoizedMtimeMs !== mtimeMs || + memoizedRaw === undefined + ) { memoizedRaw = undefined; memoizedForPath = configPath; + memoizedMtimeMs = mtimeMs; resetLoadMeta(configPath); } if (memoizedRaw !== undefined) return memoizedRaw; @@ -163,20 +291,117 @@ export function loadAgentsConfig() { enabled: pr.enabled === true, verifyRepos: pr.verifyRepos !== false, cacheTtlDays: normalizeCacheTtlDays(pr.cacheTtlDays), + onboardingPrompt: normalizeOnboardingPrompt(pr.onboardingPrompt), defaultGlobalRepos, autoSetup: normalizeAutoSetup(pr.autoSetup), }, }; } +/** + * Raw onboardingPrompt field: "auto" | "off" | "absent" (legacy / missing). + * Not normalized to auto — callers distinguish fingerprint fallback. + */ +export function getOnboardingPromptState() { + const pr = getAgentsConfigSection("packageResolution") ?? {}; + if (pr.onboardingPrompt === "off") return "off"; + if (pr.onboardingPrompt === "auto") return "auto"; + return "absent"; +} + +function normalizeOnboardingPrompt(raw) { + if (raw === "off") return "off"; + if (raw === "auto") return "auto"; + return "absent"; +} + +/** + * Deep-merge a patch into agents-conf.json (preserves unknown fields). + * `packageResolution.defaultGlobalRepos` and `autoSetup` are replaced when + * present in the patch (Consent Enable replaces the map with verified keys only). + * @param {object} patch + */ +export function mergeAgentsConfigPatch(patch) { + return withAgentsConfigLock(() => { + ensureAgentsConfigScaffoldUnlocked(); + const configPath = agentsConfigPath(); + let current = {}; + let existed = false; + try { + if (existsSync(configPath)) { + existed = true; + const parsed = JSON.parse(readFileSync(configPath, "utf8")); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error( + "agents-conf.json root must be a JSON object and was not overwritten", + ); + } + current = parsed; + } + } catch (err) { + // Never replace a malformed user config with a patch-only file. + if (existed) { + throw new Error( + `agents-conf.json is malformed and was not overwritten: ${err?.message ?? err}`, + ); + } + current = {}; + } + const next = deepMerge(current, patch); + if ( + patch?.packageResolution && + Object.prototype.hasOwnProperty.call( + patch.packageResolution, + "defaultGlobalRepos", + ) + ) { + next.packageResolution = next.packageResolution ?? {}; + next.packageResolution.defaultGlobalRepos = + patch.packageResolution.defaultGlobalRepos; + } + if ( + patch?.packageResolution && + Object.prototype.hasOwnProperty.call(patch.packageResolution, "autoSetup") + ) { + next.packageResolution = next.packageResolution ?? {}; + next.packageResolution.autoSetup = patch.packageResolution.autoSetup; + } + mkdirSync(path.dirname(configPath), { recursive: true }); + const tmp = `${configPath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`); + renameSync(tmp, configPath); + memoizedRaw = undefined; + memoizedMtimeMs = undefined; + return next; + }); +} + +function deepMerge(base, patch) { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) return patch; + const out = + base && typeof base === "object" && !Array.isArray(base) ? { ...base } : {}; + for (const [k, v] of Object.entries(patch)) { + if (v && typeof v === "object" && !Array.isArray(v)) { + out[k] = deepMerge(out[k], v); + } else { + out[k] = v; + } + } + return out; +} + export function getGlobalLogLevel() { return loadAgentsConfig().logLevel; } /** - * Package types the admin declares globally (governance source). Governance is - * the UNION of these and any workspace `.jfrog/local` repositories; the workspace - * side is added by the resolver (workspace-dependent, per-session). + * Package types the admin declares globally. Workspace overlay may add + * additional governed types for the session (see governedPackageTypes), but + * autoSetup never runs for workspace-only types. * @returns {string[]} defaultGlobalRepos keys (unordered) */ export function globalDeclaredTypes() { @@ -233,7 +458,7 @@ export function normalizeRepoMap(raw) { if (!raw || typeof raw !== "object") return {}; const out = {}; for (const [type, key] of Object.entries(raw)) { - if (typeof key === "string" && key.trim()) out[type] = key.trim(); + if (isSafeRepoKey(key?.trim())) out[type] = key.trim(); } return out; } diff --git a/plugins/jfrog/modules/core/entry.mjs b/plugins/jfrog/modules/core/entry.mjs new file mode 100644 index 0000000..476d681 --- /dev/null +++ b/plugins/jfrog/modules/core/entry.mjs @@ -0,0 +1,36 @@ +// Shared "was this module run as the CLI entrypoint?" check for the adapters. +// +// Claude invokes hooks as `${CLAUDE_PLUGIN_ROOT}/modules/.mjs`, and a +// plugin install directory is often a symlink. Node resolves the main entry to +// its real path before assigning import.meta.url, so comparing against a raw +// path.resolve(process.argv[1]) reports false under a symlinked layout and the +// hook silently becomes a no-op with exit code 0. Compare against both. + +import { realpathSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +/** + * @param {string} moduleUrl — the caller's import.meta.url + * @param {string} [entry] — defaults to process.argv[1] + */ +export function isMainEntry(moduleUrl, entry = process.argv[1]) { + if (!entry) return false; + + try { + const resolved = path.resolve(entry); + let real = resolved; + try { + real = realpathSync(resolved); + } catch { + // Entry may not exist on disk (e.g. a virtual entrypoint); use as-is. + } + return ( + moduleUrl === pathToFileURL(real).href || + moduleUrl === pathToFileURL(resolved).href + ); + } catch { + return false; + } +} diff --git a/plugins/jfrog/modules/core/io.mjs b/plugins/jfrog/modules/core/io.mjs index 13db450..230acaf 100644 --- a/plugins/jfrog/modules/core/io.mjs +++ b/plugins/jfrog/modules/core/io.mjs @@ -1,4 +1,4 @@ -// Shared stdin helpers for the subprocess-style adapters (Claude, Cursor). +// Shared stdin helpers for subprocess-style adapters (Claude, Cursor, VS Code). // // Hooks deliver their JSON payload on stdin immediately; in non-hook contexts // (CI, npm scripts, terminal smoke tests) nothing arrives, so we bail out after @@ -71,29 +71,47 @@ export function parseSessionId(stdinRaw) { } } +// Claude's documented SessionStart sources. VS Code Copilot documents only +// "new", so the two sets stay disjoint and neither can claim the other's +// sessions. +const CLAUDE_SESSION_SOURCES = new Set([ + "startup", + "resume", + "clear", + "compact", +]); + // Positively identify the harness that invoked this hook from its stdin -// payload. Returns "cursor", "claude_code", or null when it can't tell -// (no stdin — e.g. terminal smoke tests — or an unrecognized shape). +// payload. Returns "cursor", "copilot", "claude_code", or null when no harness +// left a fingerprint (no stdin — e.g. terminal smoke tests — or a shape none of +// them own). // // Why this matters: Cursor reads sessionStart hooks from BOTH // ~/.cursor/hooks.json AND ~/.claude/settings.json. Without this, a Cursor // session fires the Claude adapter too, double-injecting the policy. Each // adapter uses this to no-op when a different harness invoked it. // -// Cursor: cursor_version / agent_type. Claude: transcript_path / hook_event_name / -// session_id. Cursor also reads ~/.claude/settings.json, so each adapter no-ops -// when a different harness invoked it. +// Every branch below is a signal exactly one harness documents, and null means +// "can't tell". An adapter is only ever registered by the harness it serves, so +// a payload no harness claims is left to whichever adapter was invoked. export function detectHarness(stdinRaw) { if (!stdinRaw) return null; try { const p = JSON.parse(stdinRaw); if (!p) return null; + // Cursor stamps its own version/agent on every hook payload. if (p.cursor_version || p.agent_type === "cursor") { return "cursor"; } - if (p.transcript_path || p.hook_event_name || p.session_id) { - return "claude_code"; + if (p.hook_event_name === "SessionStart") { + // Copilot's documented `new` source is decisive. Current VS Code payloads + // also include a transcript_path, so path presence cannot classify Claude + // before the source is checked. + if (p.source === "new") return "copilot"; + if (CLAUDE_SESSION_SOURCES.has(p.source)) return "claude_code"; } + // Claude writes a transcript for non-SessionStart hooks too. + if (p.transcript_path) return "claude_code"; } catch { // stdin wasn't JSON — can't tell. } @@ -102,7 +120,8 @@ export function detectHarness(stdinRaw) { /** * Workspace roots for this hook invocation. - * Cursor: workspace_roots[]. Claude: payload cwd. Fallback: process.cwd(). + * Cursor: workspace_roots[]. Claude and VS Code Copilot: payload cwd. + * Fallback: process.cwd(). * * @param {string} [stdinRaw] * @returns {string[]} diff --git a/plugins/jfrog/modules/core/jf-identity.mjs b/plugins/jfrog/modules/core/jf-identity.mjs index b910d39..56a33e6 100644 --- a/plugins/jfrog/modules/core/jf-identity.mjs +++ b/plugins/jfrog/modules/core/jf-identity.mjs @@ -2,18 +2,24 @@ // we auth to it?". Used by feature-flag.mjs and resolver.mjs. // // Identity ALWAYS comes from `jf config`. `jf config export [serverId]` returns -// base64(JSON({ url, accessToken, serverId, ... })) for the chosen (or default) -// server. We require both `url` AND `accessToken` (Bearer-only path). +// base64(JSON({ url, accessToken, user, password, serverId, ... })) for the +// chosen (or default) server. A usable identity needs a platform `url` plus a +// credential: an access token (Bearer) OR username + password / API key +// (Basic). Access token wins when both are present (mirrors `jf setup`). +// +// After credentials parse, an optional readiness probe (Artifactory ping) +// rejects expired/revoked/unreachable credentials so the feature flag can +// fall into pending instead of "routing with empty repos". // // If `jf` is not on PATH, has no configured servers, or the chosen server has -// no access token, identity is null and the feature flag falls into the -// `missing-identity` path (hook goes no-op, fail closed). Same behaviour as -// before — only the configuration mechanism is simpler. +// no usable credential (e.g. SSH-key-only), identity is null and the feature +// flag falls into the `missing-identity` path (hook goes no-op, fail closed). // -// One subprocess per hook process. Cached after first call within the same -// process (feature-flag + resolver share one export). Not persisted across -// sessions — `jf config export` is local and fast enough to run every time. +// Config export is cached per process. Probe results are cached separately +// (async) so feature-flag can await readiness without making getPlatformIdentity +// async. +import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import process from "node:process"; @@ -21,35 +27,55 @@ 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. +/** Wire-format cause codes for getPlatformIdentity() / pending remediation. */ export const IdentityCause = Object.freeze({ OK: "ok", JF_NOT_INSTALLED: "jf-not-installed", JF_NOT_CONFIGURED: "jf-not-configured", + /** Server present but credential shape unusable (e.g. SSH-key-only). */ + JF_UNSUPPORTED_AUTH: "jf-unsupported-auth", + /** Credential present but Artifactory rejected it (401/403). */ + JF_AUTH_FAILED: "jf-auth-failed", + /** Probe timed out / network / non-auth HTTP failure. */ + JF_UNREACHABLE: "jf-unreachable", + /** Platform URL is not https — refuse to send credentials in cleartext. */ + INSECURE_URL: "insecure-url", }); +/** + * Credentials must never travel in cleartext. `jf` accepts http:// servers; + * callers that send Authorization headers must gate on https first. + * @param {{ url?: string } | string | null | undefined} identityOrUrl + */ +export function isHttpsIdentityUrl(identityOrUrl) { + try { + const raw = + typeof identityOrUrl === "string" + ? identityOrUrl + : (identityOrUrl?.url ?? ""); + return new URL(String(raw)).protocol === "https:"; + } catch { + return false; + } +} +const PROBE_TIMEOUT_MS = 3_000; + // 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. const CACHE = new Map(); +// Probe results are cached for the process lifetime (each hook is a fresh +// process, so there's nothing to expire within one). Both ok and non-ok +// results are memoized so feature-flag + resolver share one round-trip. +/** @type {Map} */ +const PROBE_CACHE = new Map(); function normalizeUrl(u) { if (!u) return ""; return String(u).replace(/\/+$/, ""); } -// 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). 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 - // "jf could not be run" (not-installed) vs "jf ran but has no usable - // server" (not-configured) so the caller can give targeted remediation. const args = ["config", "export"]; if (serverId) args.push(serverId); @@ -57,8 +83,6 @@ function jfConfigIdentity(serverId) { try { result = spawnSync("jf", args, { encoding: "utf8", - // jf config export reads no stdin and writes a single base64 line - // (no terminal interaction). 2s is plenty even for cold spawns. timeout: 2000, stdio: ["ignore", "pipe", "pipe"], }); @@ -68,9 +92,10 @@ function jfConfigIdentity(serverId) { } 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 }); + log.debug("jf spawn error", { + code: result.error.code, + message: result.error.message, + }); return { identity: null, cause: IdentityCause.JF_NOT_INSTALLED }; } if (result.status !== 0) { @@ -92,32 +117,228 @@ function jfConfigIdentity(serverId) { const json = Buffer.from(blob, "base64").toString("utf8"); parsed = JSON.parse(json); } catch (err) { - log.warn("jf config export blob not decodable", { error: err?.message ?? String(err) }); + log.warn("jf config export blob not decodable", { + error: err?.message ?? String(err), + }); return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; } const url = normalizeUrl(parsed?.url); const token = parsed?.accessToken ?? ""; + const user = parsed?.user ?? ""; + const password = parsed?.password ?? ""; const resolvedServerId = parsed?.serverId ?? serverId ?? null; - if (!url || !token) { - log.debug("jf config export missing url or accessToken", { + if (!url) { + log.debug("jf config export missing url", { serverId: resolvedServerId, - hasUrl: Boolean(url), + hasUrl: false, hasToken: Boolean(token), + hasUser: Boolean(user), + hasPassword: Boolean(password), }); return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; } + // Access token wins when both are present (mirrors jf setup precedence). + let auth = null; + if (token) { + auth = { kind: "bearer", token }; + } else if (user && password) { + auth = { kind: "basic", user, password }; + } + + if (!auth) { + log.debug("jf config export has url but no usable credential", { + serverId: resolvedServerId, + hasUrl: true, + hasToken: Boolean(token), + hasUser: Boolean(user), + hasPassword: Boolean(password), + }); + return { identity: null, cause: IdentityCause.JF_UNSUPPORTED_AUTH }; + } + + log.debug("jf config export identity accepted", { + serverId: resolvedServerId, + hasUrl: true, + authKind: auth.kind, + }); + return { - identity: { url, token, serverId: resolvedServerId, source: "jf-config" }, + identity: { + url, + serverId: resolvedServerId, + source: "jf-config", + auth, + }, cause: IdentityCause.OK, }; } -// Public — returns { identity, cause }: -// identity: { url, token, serverId, source } | null -// cause: IdentityCause.OK | JF_NOT_INSTALLED | JF_NOT_CONFIGURED +/** + * HTTP Authorization header value for Artifactory API calls, or null. + * Rejects credentials with CR/LF so Node never throws a header error that + * echoes the secret in `err.message`. + */ +export function authHeader(identity) { + const auth = identity?.auth; + if (!auth) return null; + if (auth.kind === "bearer") { + const token = String(auth.token ?? ""); + if (!token || /[\r\n]/.test(token)) return null; + return `Bearer ${token}`; + } + if (auth.kind === "basic") { + const user = String(auth.user ?? ""); + const password = String(auth.password ?? ""); + if (!user || !password || /[\r\n]/.test(user) || /[\r\n]/.test(password)) { + return null; + } + return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; + } + return null; +} + +/** Strip credential material from error strings before logging. */ +export function safeErrorMessage(err) { + const raw = err?.message ?? String(err ?? ""); + return raw + .replace(/Bearer\s+\S+/gi, "Bearer ") + .replace(/Basic\s+\S+/gi, "Basic "); +} + +function probeCacheKey(identity) { + const auth = identity?.auth; + if (!auth) return "none"; + const url = identity.url ?? ""; + if (auth.kind === "bearer") { + const digest = createHash("sha256") + .update(`bearer\0${auth.token ?? ""}`) + .digest("hex") + .slice(0, 16); + return `${url}|bearer|${digest}`; + } + const digest = createHash("sha256") + .update(`basic\0${auth.user ?? ""}\0${auth.password ?? ""}`) + .digest("hex") + .slice(0, 16); + return `${url}|basic|${digest}`; +} + +/** Test hooks only apply when the unit/integration harness sets this. */ +function testHarnessActive() { + return process.env.JFROG_TEST_HARNESS === "1"; +} + +function syntheticProbeResult() { + if (!testHarnessActive()) return null; + const mode = process.env.JFROG_TEST_IDENTITY_PROBE; + if (!mode || mode === "skip") return null; + if (mode === "ok") return { ok: true, cause: IdentityCause.OK }; + if (mode === "401" || mode === "403" || mode === "auth-failed") { + return { ok: false, cause: IdentityCause.JF_AUTH_FAILED }; + } + if (mode === "error" || mode === "unreachable") { + return { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } + return null; +} + +/** + * Probe Artifactory with the resolved credentials. Fail-closed: any non-OK + * response or network error means the identity is not ready for routing. + * + * Test hooks (require `JFROG_TEST_HARNESS=1` — never honored in production): + * JFROG_TEST_IDENTITY_PROBE=skip — do not probe; treat as ok + * ok / 401 / error — synthetic results + * + * Production kill switch: `JF_AGENT_IDENTITY_PROBE=0` skips the probe. + * + * @param {object | null} identity + * @returns {Promise<{ ok: boolean, cause: string }>} + */ +export async function probePlatformIdentity(identity) { + if (!identity) { + return { ok: false, cause: IdentityCause.JF_NOT_CONFIGURED }; + } + + const synthetic = syntheticProbeResult(); + if (synthetic) return synthetic; + + if (testHarnessActive() && process.env.JFROG_TEST_IDENTITY_PROBE === "skip") { + return { ok: true, cause: IdentityCause.OK }; + } + + if (!isHttpsIdentityUrl(identity)) { + log.warn("refusing identity probe over a non-HTTPS platform URL"); + const result = { ok: false, cause: IdentityCause.INSECURE_URL }; + const keyEarly = probeCacheKey(identity); + PROBE_CACHE.set(keyEarly, result); + return result; + } + + if (process.env.JF_AGENT_IDENTITY_PROBE === "0") { + return { ok: true, cause: IdentityCause.OK }; + } + + const key = probeCacheKey(identity); + const cached = PROBE_CACHE.get(key); + if (cached) { + return { ok: cached.ok, cause: cached.cause }; + } + + const authorization = authHeader(identity); + if (!authorization) { + const result = { ok: false, cause: IdentityCause.JF_UNSUPPORTED_AUTH }; + PROBE_CACHE.set(key, result); + return result; + } + + // Auth-required endpoint: `system/ping` is anonymous-capable, so a + // revoked/expired token would still return 200 and wrongly pass readiness. + // `system/version` requires an authenticated (non-anonymous) caller. + const pingUrl = `${identity.url}/artifactory/api/system/version`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + /** @type {{ ok: boolean, cause: string }} */ + let result; + try { + const res = await fetch(pingUrl, { + method: "GET", + headers: { Authorization: authorization }, + signal: controller.signal, + }); + if (res.status === 401 || res.status === 403) { + result = { ok: false, cause: IdentityCause.JF_AUTH_FAILED }; + } else if (!res.ok) { + result = { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } else { + result = { ok: true, cause: IdentityCause.OK }; + } + } catch (err) { + log.debug("identity probe failed", { + url: pingUrl, + error: safeErrorMessage(err), + }); + result = { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } finally { + clearTimeout(timer); + } + + log.debug("identity probe result", { + url: identity.url, + ok: result.ok, + cause: result.cause, + }); + PROBE_CACHE.set(key, result); + return result; +} + +/** + * Config-only identity (sync). Does not probe reachability. + * @returns {{ identity: object | null, cause: string }} + */ export function getPlatformIdentity() { const hint = undefined; if (CACHE.has(hint)) return CACHE.get(hint); @@ -127,6 +348,7 @@ export function getPlatformIdentity() { log.debug("identity from jf-config", { serverId: status.identity.serverId, url: status.identity.url, + authKind: status.identity.auth?.kind, }); } else { log.debug("no platform identity", { cause: status.cause }); @@ -135,20 +357,109 @@ export function getPlatformIdentity() { return status; } -/** Test-only — reset module cache between in-process scenarios. */ +/** + * Config identity + readiness probe. Prefer this from async session paths + * (feature-flag) so dead tokens fail closed to pending. + * @returns {Promise<{ identity: object | null, cause: string }>} + */ +export async function getReadyPlatformIdentity() { + const status = getPlatformIdentity(); + if (!status.identity) return status; + + const probe = await probePlatformIdentity(status.identity); + if (probe.ok) return status; + + // Rejected / structurally-unusable credentials are a stable fact → fail + // closed to pending so we don't inject "routing" with an unusable identity. + if ( + probe.cause === IdentityCause.JF_AUTH_FAILED || + probe.cause === IdentityCause.JF_UNSUPPORTED_AUTH || + probe.cause === IdentityCause.INSECURE_URL + ) { + log.debug("identity not ready after probe", { cause: probe.cause }); + return { identity: null, cause: probe.cause }; + } + + // Transient failure (timeout / network / 5xx): keep routing best-effort + // rather than downgrading a healthy setup to pending on a blip. The resolver + // already fails safe per-repo (keeps prior cache, skips empty writes). + log.warn("identity probe unreachable — routing best-effort", { + cause: probe.cause, + }); + return status; +} + +/** Test-only — reset module caches between in-process scenarios. */ export function clearPlatformIdentityCache() { CACHE.clear(); + PROBE_CACHE.clear(); } -// Short label for log lines / status output, e.g. "jf-config:". export function identityLabel(identity) { if (!identity) return "none"; return identity.serverId ? `jf-config:${identity.serverId}` : "jf-config"; } -// CLI: -// node lib/jf-identity.mjs — JSON with token redacted -// node lib/jf-identity.mjs --label — single line: "