diff --git a/.github/scripts/sync-modules-integrity.json b/.github/scripts/sync-modules-integrity.json index b31b4cc..9100bec 100644 --- a/.github/scripts/sync-modules-integrity.json +++ b/.github/scripts/sync-modules-integrity.json @@ -1,28 +1,41 @@ { "schemaVersion": 1, - "pin": "jfrog-agent-hooks/v0.9.0", + "pin": "jfrog-agent-hooks/v0.11.1", "files": { - "assets/agents-default-conf.json": "04aae9b1dcfc75271c3ed786adceea1635b0a1ae0be64fadd7b1111229f11f01", + "assets/agents-conf-fingerprints.json": "11bd418cdf38c8494e04239ae5237a1242bb1532468428c5a561f674f21e2657", + "assets/agents-default-conf.json": "774e1bfd5bb1e2f38de06c9ce53b92e12ddd36b82c159e4a3113f4c038bfc3bb", "claude-session-start.mjs": "2ca1edc6b939cdff6c5faa6ac4b69636e6e92bc7b079316e1fdee7c53c6e837b", "copilot-session-start.mjs": "8811e0829c90ff0987bed158f5ef571195ee5eb54dfc8021b4396fe76ad8a499", - "core/agents-config.mjs": "3ade16fd6e08b8ac6cb8570edfbed1e9677b26d9c31513720680dd17513480af", + "core/agent-guard-check.mjs": "fd7fe9df640418b0df3296a67c33dfabed97f65e210f6e7abea5bce96fa68834", + "core/agents-config.mjs": "743caf9ea1c6d6db35f8854c533b1e17d6449547ed0914ce71385f9812811b7e", + "core/entry.mjs": "0b0b218448151d7a06743c37e684d0933ab76225be5761f648627f4db02c1f17", "core/io.mjs": "63ea75df635a4e15cf36f2158fe78ae42e3ca886abe267ee5ed2577042eb153d", - "core/jf-identity.mjs": "9d0301d4a60b9c9297cde24e0bab0c2660c56f831617f2b69db17c844276b19b", + "core/jf-identity.mjs": "4ee1c17b6e737f29aa0a2d2f0c94edad6b4566ecaf8f5b5bed0e45486e4f720a", + "core/jf-user-agent.mjs": "deb8fdeb72b34c70aed8373ca7d7424c6a15575e391cd24fb3471b46ed62e53f", "core/logger.mjs": "1ebdffcdf4af14b19e3ee8e82cfeb377fb9961a09d4e9d6ecdc922d07b0848c2", + "core/rewrite-mcp-json.mjs": "a88733edd33bd146ed960c157e085f0b89a20882719c1d6daee5a56400322d86", "core/run-capability.mjs": "9fac890b7fd4866f9d3322469b2a7301e28cebfa3faebd77857f9f79d2d1c532", + "core/scaffold-fingerprint.mjs": "df7a710ba215e74808fbda5322e353171a613e74a3ac90fbca466d70c91dca67", "cursor-session-start.mjs": "37dd25ffee18e9f357e3cbb8453552766fd89295e85aa09bf93bc208df74aa20", + "package-resolution/onboarding/package-resolution-onboarding-procedure.md": "53b71301a1fcacd187a30239b780dd4fff451a6d03612d6dbb05aa35b7735c8c", + "package-resolution/onboarding/session-start-nudge.md": "551a254f4fcce26011baf0612ede2c25f603c5269366e6d093f7321d16c5b9e7", + "package-resolution/scripts/apr-heartbeat.mjs": "3b87b52c06afc5fe311df9c8ecfd29c97198f4b6e1881920ba28231358dc466b", + "package-resolution/scripts/configure.mjs": "06377ac044c295f00f3510ed78f2637e4e35477e87932b04f41e4311b2765ac9", "package-resolution/scripts/eager-setup-receipt.mjs": "69213084bc1976ec63b346ca26e8a63eb713b0da7ad6ab4fc018934e70fef091", - "package-resolution/scripts/eager-setup.mjs": "d78fc422d15a271e9ae68b754a28fa72ea25a9e86b505b1a0e5e053add864c0d", - "package-resolution/scripts/feature-flag.mjs": "18b258e4d1999de31bad54a1f7f3c3f9cf65c598bbb83f328b45f68a739821f3", - "package-resolution/scripts/index.mjs": "f3ea8f71ecd156515a4a5eb14e33de5c61287f4f2e0e7ca90f9b7d010c4d6567", + "package-resolution/scripts/eager-setup.mjs": "aedfbb7790d55d77f4189cbf42aef0ed0f8ed3f23fb4047e885a7741c550076f", + "package-resolution/scripts/feature-flag.mjs": "d5aa11e51d8127ff3b07c75b1e7294c5a10846c40b63da8140116f250ead1788", + "package-resolution/scripts/index.mjs": "3fc07afce04d259d7b2d4cebabacf8875c318519ac293cba7f1622f13c6711e8", + "package-resolution/scripts/onboarding-decline-cache.mjs": "776841ecf76f9c0367b97e774b6176a8431b26631d3c44c95b9e4b91becbc849", + "package-resolution/scripts/onboarding.mjs": "b1482fe60762c89c09939b4778762f8449a1824e25f9dcc6270b5ce44d71c62a", "package-resolution/scripts/package-manager-family.mjs": "50d066910638e37c2375f696094fde1252181398be56c4218ca14342a76a34e5", "package-resolution/scripts/print-policy.mjs": "02593c6e401006d226b908221d007ba9e1951a61c4cb060789877c1fe919faa2", - "package-resolution/scripts/render-instruction.mjs": "9e4205b5715e2c79515de19d473a6487d61971368103f2851388530ed0a180c4", + "package-resolution/scripts/render-instruction.mjs": "e7faf708b09ffea3b9296ef2dc107d5ddd157ab6849df7d3fb96a5153f78f97a", "package-resolution/scripts/repo-types.mjs": "b432bcdd6e77f80ca2c9dddfbf4d9e1299019758fd58b04b29fc21b18a86f788", - "package-resolution/scripts/resolver.mjs": "3485575a65fd5579420d69a51f723047d44f3cfa246511565c6e89ca32b02b4d", + "package-resolution/scripts/resolver.mjs": "49079e592b4f5c722c671447b856f076161b42996c864c96fbdc6cd82b2709ff", "package-resolution/scripts/setup-conflict.mjs": "fdc589561813a9c5e708f50a20a87bacdad3f490158c973b12b217cb984e2845", + "package-resolution/scripts/verify-repo.mjs": "ad0a1cc04c1dddd92e29fefb27ae90e69afb368b8d208d22ab4fda6a27dfe34a", "package-resolution/scripts/workspace-config.mjs": "f8f8eaaf0fb8a0c3691938e99afebbc87d8779e508db0ef821fa23f0a55b786a", - "package-resolution/templates/package-resolution-unconfigured.md": "e7645b89d1c4d618fb45692de084d627ca24b5e2e975416176115d3c233e9c00", - "package-resolution/templates/package-resolution.md": "c305751d24fe352b6334a208f7831eb9db58704f1baa693c6921264f6a4456d1" + "package-resolution/templates/package-resolution-unconfigured.md": "d276aa796b3c38f0566bc0b5c3a1553bbbf322d5eaa0d60841e21a3e280a91e0", + "package-resolution/templates/package-resolution.md": "f432ea47e99db08b6223873eb4560f814369f92673acdff2b0de6fa7e10a1d58" } } diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index aece003..18010f5 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -1,6 +1,8 @@ { "repo": "JFROG/jfrog-agent-hooks", - "pin": "jfrog-agent-hooks/v0.9.0", - "paths": ["modules"], + "pin": "jfrog-agent-hooks/v0.11.1", + "paths": [ + "modules" + ], "dest_prefix": "plugin" } diff --git a/README.md b/README.md index fcdfaf6..ee3d5d4 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,12 @@ After authentication, open a workspace in VS Code. The JFrog skills load on dema ### Agent Package Resolution -When Agent Package Resolution is enabled in `~/.jfrog/agents-conf.json`, a -SessionStart hook adds the resolved Artifactory repositories and package-routing -rules to every new Copilot chat. Configure the JFrog CLI with `jf config add`, -then start a new chat after changing the configuration. +The shipped template enables Agent Package Resolution with empty repository +bindings (nothing is routed until Consent Enable or an admin adds +`defaultGlobalRepos`). When it is on, a SessionStart hook injects the resolved +Artifactory repositories and package-routing policy into every new Copilot chat. +Configure the JFrog CLI with `jf config add`, then start a new chat after +changing the configuration. The feature is fail-open for the chat session: disabled or unexpected failure returns an empty hook result instead of preventing Copilot from starting. An diff --git a/docs/package-resolution-admin-guide.md b/docs/package-resolution-admin-guide.md index a008ead..3e317bf 100644 --- a/docs/package-resolution-admin-guide.md +++ b/docs/package-resolution-admin-guide.md @@ -1,12 +1,379 @@ -# Agent Package Resolution — VS Code administrator guide +# Agent Package Resolution: Admin Guide (Preview) -Agent Package Resolution is opt-in. Deploy -`~/.jfrog/agents-conf.json` through the organization’s normal device-management -system; the plugin never overwrites an existing file. +Route AI-assisted package installs through your JFrog Artifactory repositories when developers use the **JFrog plugin** for Cursor, Claude Code, or VS Code. -## Recommended configuration +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. -Declare only repositories approved for the organization: +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. + +--- + +## Setup summary + +| 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 shipped template turns Agent Package Resolution **on** (`enabled: true`) with empty `defaultGlobalRepos`. Nothing is routed until Consent Enable or an administrator adds bindings. Set `enabled: false` or `JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1` to keep it off. + + +**At a glance:** + +- **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. + + +--- + +## Prerequisites + +- **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 + +| 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 | + +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 the developer machine: + +``` +~/.jfrog/agents-conf.json +``` + +| Property | Description | +| --------------------- | -------------------------------------------------------------------------------- | +| **Scope** | Per user profile (`$HOME`) | +| **Written by** | Administrators (MDM, golden image, manual edit) or auto-created on first session | +| **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 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 durably declined, SessionStart injects a short **onboarding nudge** directly into the agent's context (`additional_context` for Cursor, `additionalContext` for Claude Code and VS Code Copilot) — **all three harnesses get it**; nothing is written to disk for the nudge itself. SessionStart injects it on every eligible session; the injected text itself instructs the agent to hold off raising it until a real package-manager install is happening, not on every unrelated chat. It names only the still-offerable types, so it only ever shrinks as types get bound or declined; it is injected fresh on every eligible SessionStart. +4. The offer is **per package type**, not one-time-and-done. **No** for one type runs `dismiss --type `, which durably declines just that type in `~/.jfrog/skills-cache/apr-onboarding-v1.json` — other unbound, undeclined types stay offerable. **Yes** runs Consent Enable / `enable` for binding, which stops offering just the types that got bound. A bare `dismiss` (no `--type`) is the global escape hatch: it sets `onboardingPrompt: "off"` and durably silences every type until that config value changes. +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, for any type — global silence (bare `dismiss`, or admin-set) | +| `"auto"` | Explicit opt-in — keep offering whichever types remain unbound and undeclined | +| 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`). + +### Consent Enable (developer chat flow) + +When the nudge fires, the agent walks the developer through enabling APR **in chat** (no hand-editing JSON): + +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; the types just bound stop being offered, other unbound/undeclined types keep being offered: + ```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. + +Other `configure.mjs` commands: `status [--json]` (includes `offerable`/`declined` type lists), `onboarding-procedure` (prints the full Consent Enable steps — the injected nudge 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": true, + "verifyRepos": true, + "cacheTtlDays": 7, + "onboardingPrompt": "auto", + "defaultGlobalRepos": {}, + "autoSetup": [] + } +} +``` + +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 the onboarding +nudge may still offer to bind whichever types remain unbound and undeclined 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) + +> **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. + +**Pick the option that matches how you manage machines:** + +| 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` | + +**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. + +## Admin control: deploy `agents-conf.json` across your organization + +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 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 org-wide | Set `"packageResolution": { "enabled": true, ... }` in the deployed file | +| 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` | +| 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`) | + +--- + +## Operating modes + +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 | No routing/pending policy. If `enabled` is simply `false` and the offer gate is open, the eligible onboarding nudge can still be injected; the disable env var suppresses that too | +| **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` | + +`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 **admin-declared** 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)). Workspace-only types never run eager setup. + +### Agent decision flow (routing mode) + +The injected session template carries the canonical **Decision order** (same matrix the agent must follow). + +**Do not conflate these three signals:** + +| 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 | + +**Decision order (first match wins)** — mirrored in the injected template: + +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 | + +--- + +## Configuration reference + +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 **admin** governed types yet; a resolved workspace overlay can still govern types in that project. | +| `verifyRepos` | `true` | When `true`, each repo key in `defaultGlobalRepos` **and** in the workspace overlay 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**. These keys are the **admin** governed set (workspace overlay can add resolved types; see below). `configure.mjs enable --repos` **replaces** this map (it does not merge) | +| `autoSetup` | `[]` | **Admin-declared** types to auto-configure with `jf setup` at session start. Array of type names, or `true` for all admin types (not workspace-only). `configure.mjs auto-setup --types` **replaces** this list. See [Zero-touch setup](#zero-touch-setup-autosetup) | + +**Supported package types:** `npm`, `pypi`, `maven`, `gradle`, `go`, `docker`, `helm`, `nuget`. + +**Governed vs. ungoverned.** A package type is **governed** when it is an +administrator key in `defaultGlobalRepos`, **or** a workspace +`.jfrog/local/package-resolution.json` key that **resolved** this session +(validated + verified when `verifyRepos` is on). A workspace file can override +the repository for an admin type or add a type. A workspace-only type that +fails verification is dropped (not shown, not blocked). Only governed types +appear in the injected policy: + +- **Governed + resolved** — routed: a table row + rewrite rule. Eager `jf setup` (`autoSetup`) runs only when the type is **also** in `defaultGlobalRepos`. +- **Governed + unresolved** (admin-declared 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. +- **Ungoverned** (not admin-declared and not a resolved workspace overlay) — **out of scope**: omitted from the policy entirely. + +### 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) + +--- + +## Selective governance: choose which package types to route + +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 project**, add a supported key in `.jfrog/local/package-resolution.json` (Artifactory verification only when `verifyRepos` is on). That type is in policy for the session; it is **not** autoSetup-eligible. +- **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: + +```json +{ + "packageResolution": { + "enabled": true, + "defaultGlobalRepos": { + "pypi": "corp-pypi-virtual" + } + } +} +``` + +A workspace can override an administrator-approved repository key for its own +checkout. The override is 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`; everything else stays out of scope. Adding another +validated key in the workspace file (for example `"npm": "team-npm-virtual"`) +would govern npm **in that project only**, without running eager `jf setup` for it. + +--- + +## 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 **admin-declared** types" (not workspace-only). +- It is **repo-agnostic**: setup targets whatever repo actually resolves for that type this session (a workspace override of an admin type wins over the org default). +- Only types that are **in `defaultGlobalRepos` and resolved** are eligible. Workspace-only types are skipped even when `autoSetup` is `true`. Names that aren't admin-declared 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 + +### Enable Agent Package Resolution with your repository keys ```json { @@ -15,28 +382,135 @@ Declare only repositories approved for the organization: "enabled": true, "verifyRepos": true, "cacheTtlDays": 7, + "defaultGlobalRepos": { + "npm": "corp-npm-virtual", + "pypi": "corp-pypi-virtual", + "maven": "corp-maven-virtual", + "gradle": "corp-gradle-virtual", + "go": "corp-go-virtual", + "docker": "art-docker", + "helm": "corp-helm-local", + "nuget": "corp-nuget-virtual" + } + } +} +``` + +Deploy this file to `~/.jfrog/agents-conf.json` on developer machines, then have users start a **new agent session**. + +### npm and Docker only (minimal rollout) + +```json +{ + "packageResolution": { + "enabled": true, "defaultGlobalRepos": { "npm": "npm-virtual", - "pypi": "pypi-virtual" - }, - "autoSetup": [] + "docker": "docker-virtual" + } + } +} +``` + +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 + +```json +{ + "logLevel": "debug", + "packageResolution": { + "enabled": true + } +} +``` + +Inspect `~/.jfrog/logs/agent-hooks.log` on the developer machine. Return to `"logLevel": "info"` after troubleshooting. + +--- + +## 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** (explicit `enabled: false`, or a hand-edited file that is not the shipped scaffold) + +### macOS / Linux (Zsh or Bash) + +Add to the IDE launch environment or shell profile: + +```bash +export JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1 +``` + +Restart the coding assistant after changing environment variables. + +### 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 + +Developers (or project templates) can override global defaults for a specific repository checkout: + +**File:** `/.jfrog/local/package-resolution.json` + +```json +{ + "repositories": { + "npm": "team-npm-virtual", + "pypi": "team-pypi-virtual" } } ``` -Repository keys are verified against Artifactory before routing. Invalid or -unreachable repositories remain unresolved rather than falling back to public -registries. Workspace files may replace a repository for an already-governed -package type, but cannot expand the governed scope. +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`, 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 | Admin types come from `defaultGlobalRepos`. Workspace-only types appear only when the overlay key is supported (and verified when `verifyRepos` is on); a failed workspace-only key is dropped | +| `autoSetup` type not auto-configured | Must be **admin-declared** + resolved and in `routing` mode (workspace-only types never run eager `jf setup`); 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 | -`cacheTtlDays: 0` re-checks all cached state on every session, including -eligible zero-touch `jf setup` receipts. Use it temporarily when troubleshooting, -not as a normal operating setting. +--- -## Rollout checklist +## Related documentation -1. Configure and test `jf` on a non-production server. -2. Deploy the configuration to a pilot group. -3. Enable `"chat.plugins.enabled": true` and `"chat.useHooks": true` in VS Code. -4. Start a new Copilot chat and verify the resolved repository table. -5. Monitor `~/.jfrog/logs/agent-hooks.log` before expanding rollout. +- [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 6545657..297800d 100644 --- a/docs/package-resolution-user-guide.md +++ b/docs/package-resolution-user-guide.md @@ -1,51 +1,140 @@ -# Agent Package Resolution — VS Code user guide +# Agent Package Resolution: User Guide (Preview) -Agent Package Resolution adds the Artifactory repository policy to every new -Copilot chat. It is advisory context for the agent; JFrog Curation and -package-manager configuration provide the enforcement layer. +**Audience:** Users of Cursor, Claude Code, or VS Code Copilot with the JFrog plugin, whether or not you're a professional developer. -## Prerequisites +You (or your org) installed the JFrog plugin. This is what happens next, step by step, when you ask your agent to do something that needs a package: install a dependency to build an app, pull a Docker image, and so on. -Enable both VS Code settings: +--- -```json -{ - "chat.plugins.enabled": true, - "chat.useHooks": true -} -``` +## Prerequisite -Install Node.js 20 or newer and configure the JFrog CLI: +The JFrog plugin is installed. That's it; nothing else is required of you up front. -```bash -jf config add -``` +On **VS Code Copilot**, also enable both settings (`chat.plugins.enabled` and `chat.useHooks`) so the plugin and SessionStart hook load. + +## What will happen, by case + +You're mostly passive in all of this: the agent drives, and it tells you when it needs something from you. + +### CLI installed and authenticated + +- **You:** nothing. Just ask: + +> "Add lodash as a dependency" +> "Pull the alpine image and start a container" + +- **Agent:** routes your request through your organization's Artifactory right away. + +This is the state you'll be in almost all the time. -Start a new Copilot chat after changing configuration. If `jf` is missing or -not configured, the hook returns a `NOT READY` advisory and does not invent a -public or unverified repository. +### Server not configured -## Enable routing +- **Agent:** asks you for your JFrog Platform URL, then starts a login against it (`jf config add` / `jf login`). +- **You:** provide the URL and complete the login when prompted. -An administrator must enable the feature and declare the package types: +### CLI not installed + +- **Agent:** installs the CLI. +- **You:** may need to approve the install (a normal IDE tool-permission prompt). + +### CLI not authenticated + +- **Agent:** launches a login (`jf login`, usually a browser session). +- **You:** complete the login when prompted. + +--- + +## Turning it on + +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`. + +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 { "packageResolution": { "enabled": true, "defaultGlobalRepos": { - "npm": "npm-virtual" + "npm": "npm-virtual", + "pypi": "pypi-virtual" } } } ``` -Use repository keys that exist on the configured JFrog server. A workspace -`.jfrog/local/package-resolution.json` can override an administrator-approved -type, but cannot add a new governed type. +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 + +If routing is causing problems (wrong repository, broken installs, anything else), you can turn Agent Package Resolution off immediately without touching `agents-conf.json`: + +```bash +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) + +- **First time a project uses a given package type,** the agent may show a quick one-time confirmation ("apply this setup?") before it can route that package type. You just confirm; it's the agent doing setup work, not something you prepare for, and it won't ask again for that project. +- **Your admin may skip that confirmation entirely.** If they've turned on zero-touch setup for a package type, the plugin starts binding it to Artifactory automatically in the background when you start a session. You usually won't see a prompt for that package type. Because binding runs in the background, a very early first request in the same session can occasionally land before setup finishes. + +--- ## Troubleshooting -If no policy appears, check `~/.jfrog/agents-conf.json`, `jf config show`, and -start a fresh chat. Set `logLevel` to `debug` temporarily and inspect -`~/.jfrog/logs/agent-hooks.log`. +| 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 | 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 | + +--- + +## Feedback + +This is a preview, and your feedback directly shapes what ships next. Please tell us about anything that felt confusing, broken, or surprising, good or bad. + +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) +- VS Code: [github.com/jfrog/vscode-plugin/issues](https://github.com/jfrog/vscode-plugin/issues) +- Email: plugins-feedback@jfrog.com + +--- + +## Appendix: background and details + +### What is Agent Package Resolution, technically + +It's a feature in the JFrog plugin that runs at the start of every coding-agent session. When enabled, it checks routing readiness (as above) and, once ready, gives the agent the resolved Artifactory URL for each package type you use, so installs are routed there instead of the public registry, without changing your workflow. + +### About this preview (please read) + +- **This steers the agent, it does not hard-block installs.** The checks above happen because the agent is instructed to follow them, not because commands are intercepted or rewritten as you type them. If an install doesn't get routed the way you expect, that's useful feedback for us. +- **The real backstop is your package manager configuration plus Artifactory Curation**, which your admin sets up server-side. Once a package manager is bound to a repository for a project (the one-time setup step above), that binding is durable and persists across sessions, independent of this feature. +- This is a **preview**; you may hit rough edges. Please tell us about them. + +### Advanced: project-specific repository overrides + +If you're working in a repository that needs a different Artifactory repository than your org's default (for example, a team-specific mirror), you or your team can add a file to the project: + +**File:** `/.jfrog/local/package-resolution.json` + +```json +{ + "repositories": { + "npm": "team-npm-virtual", + "pypi": "team-pypi-virtual" + } +} +``` + +This overrides your org's default repository for the listed package types, for anyone working in that project. Most users won't need this; it's here for teams with special routing needs. It only changes which repository is used; it doesn't turn Agent Package Resolution on by itself, that still happens in `agents-conf.json` (see [Turning it on](#turning-it-on)). diff --git a/marketplace.json b/marketplace.json index 8953ed2..ff7eae0 100644 --- a/marketplace.json +++ b/marketplace.json @@ -9,7 +9,7 @@ { "name": "jfrog", "description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices", - "version": "1.0.19", + "version": "1.0.20", "license": "Apache-2.0", "source": "plugin", "categories": [ diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 4450e32..4e66c45 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices", - "version": "1.0.19", + "version": "1.0.20", "license": "Apache-2.0", "author": { "name": "JFrog", diff --git a/plugin/modules/assets/agents-conf-fingerprints.json b/plugin/modules/assets/agents-conf-fingerprints.json new file mode 100644 index 0000000..59a9182 --- /dev/null +++ b/plugin/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/plugin/modules/assets/agents-default-conf.json b/plugin/modules/assets/agents-default-conf.json index e2b035c..35ceff5 100644 --- a/plugin/modules/assets/agents-default-conf.json +++ b/plugin/modules/assets/agents-default-conf.json @@ -1,9 +1,10 @@ { "logLevel": "info", "packageResolution": { - "enabled": false, + "enabled": true, "verifyRepos": true, "cacheTtlDays": 7, + "onboardingPrompt": "auto", "defaultGlobalRepos": {}, "autoSetup": [] } diff --git a/plugin/modules/core/agent-guard-check.mjs b/plugin/modules/core/agent-guard-check.mjs new file mode 100644 index 0000000..8789667 --- /dev/null +++ b/plugin/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/plugin/modules/core/agents-config.mjs b/plugin/modules/core/agents-config.mjs index bf10d77..288c290 100644 --- a/plugin/modules/core/agents-config.mjs +++ b/plugin/modules/core/agents-config.mjs @@ -4,11 +4,15 @@ // 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"; @@ -29,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: "" }; @@ -38,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 { @@ -81,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; @@ -164,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 (the governance boundary). - * Workspace files may override repository keys for these types but cannot add - * new governed types. + * 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() { diff --git a/plugin/modules/core/entry.mjs b/plugin/modules/core/entry.mjs new file mode 100644 index 0000000..476d681 --- /dev/null +++ b/plugin/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/plugin/modules/core/jf-identity.mjs b/plugin/modules/core/jf-identity.mjs index 33367d1..56a33e6 100644 --- a/plugin/modules/core/jf-identity.mjs +++ b/plugin/modules/core/jf-identity.mjs @@ -38,8 +38,26 @@ export const IdentityCause = Object.freeze({ 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` @@ -251,6 +269,15 @@ export async function probePlatformIdentity(identity) { 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 }; } @@ -346,7 +373,8 @@ export async function getReadyPlatformIdentity() { // 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.JF_UNSUPPORTED_AUTH || + probe.cause === IdentityCause.INSECURE_URL ) { log.debug("identity not ready after probe", { cause: probe.cause }); return { identity: null, cause: probe.cause }; @@ -420,6 +448,12 @@ function noIdentityHint(cause) { "platform URL, then retry." ); } + if (cause === IdentityCause.INSECURE_URL) { + return ( + "Configured platform URL is not HTTPS. Reconfigure with `jf config add` " + + "using an https:// URL so credentials are not sent in cleartext." + ); + } return ( "No configured JFrog server. Run `jf config add` (access token or " + "username + password / API key)." diff --git a/plugin/modules/core/jf-user-agent.mjs b/plugin/modules/core/jf-user-agent.mjs new file mode 100644 index 0000000..7b3455b --- /dev/null +++ b/plugin/modules/core/jf-user-agent.mjs @@ -0,0 +1,105 @@ +// Thin JFROG_CLI_USER_AGENT for jf spawned by APR (eager setup + heartbeat). +// +// Stamp only what sessionStart actually knows right now: +// - trigger=hook +// - jfrog-skills/ (Coralogix product filter unity) +// - jfrog-cli-go/ +// - tool= from adapter ctx.ide (via JFROG_APR_UA_TOOL) +// - client= from TERM_PROGRAM when present in this process +// +// Do NOT stamp model= — skills/agent own the model slug and set it when the +// agent is actually running with a known model (usually a later bash tool). +// Spawn env is inherited so CLI DetectExecutionContext can append +// ai-agent/ / ai-client/ / ai-model/ when those signals exist at jf start. + +import { spawnSync } from "node:child_process"; + +// Plugin sync stamps this literal with the release semver (jfrog-sync-modules.py +// stamp). Only `modules/` is vendored, so nothing outside this tree is readable +// at runtime. Unstamped trees (this repo, local dev) report 0.0.0. +const PKG_VERSION = "0.11.1"; + +const MAX_TOKEN_LEN = 64; + +/** @type {string | undefined} */ +let cachedCliVersion; + +/** + * @param {string | undefined | null} raw + * @returns {string} + */ +export function sanitizeToken(raw) { + if (raw == null || raw === "") return ""; + let s = String(raw) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, ""); + if (s.length > MAX_TOKEN_LEN) s = s.slice(0, MAX_TOKEN_LEN); + return s; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {string} + */ +function resolveCliVersion(env = process.env) { + if (env.JFROG_TEST_CLI_VERSION) return String(env.JFROG_TEST_CLI_VERSION); + if (cachedCliVersion) return cachedCliVersion; + try { + // Keep process PATH/HOME even when callers pass a sparse env object + // (unit tests often pass only UA-related keys). + const res = spawnSync("jf", ["--version"], { + encoding: "utf8", + timeout: 3000, + env: { ...process.env, ...env }, + }); + const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`; + const m = out.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/); + cachedCliVersion = m?.[1] || "unknown"; + } catch { + cachedCliVersion = "unknown"; + } + return cachedCliVersion; +} + +/** + * Axes present on the hook process itself (not invented, not model). + * @param {NodeJS.ProcessEnv} [env] + * @param {{ tool?: string }} [opts] + * @returns {{ tool?: string, client?: string }} + */ +export function resolveHookUaAxes(env = process.env, opts = {}) { + const tool = + sanitizeToken(opts.tool) || + sanitizeToken(env.JFROG_APR_UA_TOOL) || + undefined; + const client = sanitizeToken(env.TERM_PROGRAM) || undefined; + return { tool, client }; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {{ tool?: string }} [opts] + * @returns {string} + */ +export function buildHookJfUserAgent(env = process.env, opts = {}) { + const axes = resolveHookUaAxes(env, opts); + const parts = ["trigger=hook"]; + if (axes.tool) parts.push(`tool=${axes.tool}`); + if (axes.client) parts.push(`client=${axes.client}`); + return `jfrog-skills/${PKG_VERSION} (${parts.join("; ")}) jfrog-cli-go/${resolveCliVersion(env)}`; +} + +/** + * Spawn env: full inherit + hook UA override. + * @param {NodeJS.ProcessEnv} [env] + * @param {{ tool?: string }} [opts] + * @returns {NodeJS.ProcessEnv} + */ +export function envWithHookUserAgent(env = process.env, opts = {}) { + return { ...env, JFROG_CLI_USER_AGENT: buildHookJfUserAgent(env, opts) }; +} + +/** @internal test helper */ +export function _resetCliVersionCacheForTests() { + cachedCliVersion = undefined; +} diff --git a/plugin/modules/core/rewrite-mcp-json.mjs b/plugin/modules/core/rewrite-mcp-json.mjs new file mode 100644 index 0000000..8adcc47 --- /dev/null +++ b/plugin/modules/core/rewrite-mcp-json.mjs @@ -0,0 +1,1147 @@ +// Shared Agent Guard `--rewrite-mcp-json` runner for harness adapters. +// +// Harness plugins own path discovery; this module owns: +// resolve server/project → discover → skip-if-current → Step 0 gate → +// spawn/timeout, soft-fail orchestration with structured outcomes. +// Server id is resolved once for both the gate and AG --server (always passed). +// +// Usage (from a thin Cursor/Claude script next to synced modules/): +// import { runRewriteMcpJsonPipeline } from "./modules/core/rewrite-mcp-json.mjs"; +// const result = await runRewriteMcpJsonPipeline({ +// discover: () => [...absoluteMcpJsonPaths], +// allowRoots: [...], +// }); +// // result: { exitCode, outcome, reason } — exitCode is 0 unless STRICT=1 +// +// Kill switch: JF_AGENT_REWRITE_MCP_JSON_DISABLE=1 → soft no-op (exit 0). +// Force refresh: JF_AGENT_REWRITE_MCP_JSON_FORCE=1 → ignore skip marker. +// Strict: JF_AGENT_REWRITE_MCP_JSON_STRICT=1 → failed_* outcomes exit 1. +// Local binary: JFROG_AGENT_GUARD_BIN=/path/to/agent-guard (skips npx). +// Version pin: JFROG_AGENT_GUARD_VERSION (default DEFAULT_AGENT_GUARD_VERSION). + +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; + +import { EXIT_ENABLED, runAgentGuardCheck } from "./agent-guard-check.mjs"; +import { createLogger } from "./logger.mjs"; + +const log = createLogger("rewrite-mcp-json"); + +export const AGENT_GUARD_PACKAGE = "@jfrog/agent-guard"; +export const DISABLE_ENV = "JF_AGENT_REWRITE_MCP_JSON_DISABLE"; +export const FORCE_ENV = "JF_AGENT_REWRITE_MCP_JSON_FORCE"; +export const STRICT_ENV = "JF_AGENT_REWRITE_MCP_JSON_STRICT"; +export const AGENT_GUARD_BIN_ENV = "JFROG_AGENT_GUARD_BIN"; +/** + * Default npm registry for `npx @jfrog/agent-guard` during mcp.json rewrite. + * + * Exception to the usual "no runtime hard-dep on releases.jfrog.io" bundling + * rule: package-resolution hooks are fully vendored, but Agent Guard's MCP + * rewrite intentionally fetches `@jfrog/agent-guard` at session start via + * npx from the public `coding-agents-npm` channel (override with + * JFROG_AGENT_GUARD_REPO / JFROG_AGENT_GUARD_BIN). See .cursor/rules/bundling.mdc. + */ +export const DEFAULT_AGENT_GUARD_NPM_REGISTRY = + "https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/"; +/** + * Pinned so a session start cannot execute whatever the registry currently + * tags as latest. Bump deliberately; JFROG_AGENT_GUARD_VERSION overrides + * (including "latest"). First release validated with `--rewrite-mcp-json`. + */ +export const DEFAULT_AGENT_GUARD_VERSION = "1.6.0"; +/** + * Shared budget for rewriting all discovered files in one hook invocation. + * Kept under the harness hook timeout (Cursor sessionStart is 60s); do not + * raise this to match the hook timeout. + */ +export const DEFAULT_REWRITE_TIMEOUT_MS = 35_000; +/** SIGTERM → SIGKILL escalation window for a child that ignores the first signal. */ +export const DEFAULT_KILL_GRACE_MS = 2_000; + +/** Newest setup.json "version" this code understands (best-effort on mismatch). */ +export const SUPPORTED_SETUP_FILE_VERSION = 1; + +export const OUTCOME = Object.freeze({ + DISABLED: "disabled", + SKIPPED_CURRENT: "skipped_current", + SKIPPED_NO_PATHS: "skipped_no_paths", + SKIPPED_NO_PROJECT: "skipped_no_project", + SKIPPED_NO_SERVER: "skipped_no_server", + SKIPPED_UNSAFE_PROJECT: "skipped_unsafe_project", + SKIPPED_UNSAFE_SERVER: "skipped_unsafe_server", + SKIPPED_GATE: "skipped_gate", + FAILED_DISCOVER: "failed_discover", + FAILED_GATE: "failed_gate", + FAILED_ALLOW_ROOTS: "failed_allow_roots", + FAILED_SPAWN: "failed_spawn", + REWRITTEN: "rewritten", +}); + +/** + * @param {string} outcome + * @param {string} [reason] + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ exitCode: number, outcome: string, reason: string }} + */ +export function pipelineResult(outcome, reason = "", env = process.env) { + const failed = String(outcome).startsWith("failed_"); + const exitCode = failed && env[STRICT_ENV] === "1" ? 1 : 0; + return { exitCode, outcome, reason }; +} + +export function isRewriteDisabled(env = process.env) { + return env[DISABLE_ENV] === "1"; +} + +export function isRewriteForced(env = process.env) { + return env[FORCE_ENV] === "1"; +} + +/** + * True when JFROG_URL/JF_URL + access token are set. + * Used by the gate (Path A); plugin rewrite always passes `--server` separately. + * @param {NodeJS.ProcessEnv} [env] + */ +export function hasJfrogUrlTokenEnv(env = process.env) { + const url = env.JFROG_URL?.trim() || env.JF_URL?.trim(); + const token = env.JFROG_ACCESS_TOKEN?.trim() || env.JF_ACCESS_TOKEN?.trim(); + return Boolean(url && token); +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * @param {string} [url] + * @returns {string} + */ +export function normalizeJpdUrl(url) { + return String(url ?? "") + .trim() + .replace(/\/+$/, ""); +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {string} + */ +export function resolveJfrogHomeDir(env = process.env) { + const fromEnv = env.JFROG_CLI_HOME_DIR?.trim(); + if (fromEnv) return fromEnv; + return path.join(homedir(), ".jfrog"); +} + +/** + * Default skip-if-current marker under the jf CLI home. + * @param {NodeJS.ProcessEnv} [env] + * @returns {string} + */ +export function defaultRewriteMarkerPath(env = process.env) { + return path.join( + resolveJfrogHomeDir(env), + "agent-hooks", + "rewrite-mcp-json.marker", + ); +} + +/** + * Mirror of Agent Guard `ActiveProjectFromSetupFile`: read + * `{JFROG_CLI_HOME}/setup.json` → servers[id].currentActiveProject. + * Never throws; returns "" when missing/unreadable/no match. + * + * @param {string} serverId + * @param {string} [jpdUrl] + * @param {{ + * env?: NodeJS.ProcessEnv, + * readFileSyncFn?: typeof readFileSync, + * setupPath?: string, + * }} [opts] + * @returns {string} + */ +export function activeProjectFromSetupFile(serverId, jpdUrl = "", opts = {}) { + const env = opts.env ?? process.env; + const readFn = opts.readFileSyncFn ?? readFileSync; + const setupPath = + opts.setupPath ?? path.join(resolveJfrogHomeDir(env), "setup.json"); + const wantUrl = normalizeJpdUrl(jpdUrl); + const id = String(serverId ?? "").trim(); + + let raw; + try { + raw = readFn(setupPath, "utf8"); + } catch (err) { + if (err?.code === "ENOENT") { + log.debug("setup file: not found", { path: setupPath }); + } + return ""; + } + + /** @type {{ version?: number, servers?: Record }} */ + let sf = {}; + try { + sf = JSON.parse(raw); + } catch { + return ""; + } + if (!isPlainObject(sf) || !isPlainObject(sf.servers)) return ""; + if ( + typeof sf.version === "number" && + sf.version !== SUPPORTED_SETUP_FILE_VERSION + ) { + // Best-effort parse (matches AG). + } + + const servers = sf.servers; + if (id) { + const entry = servers[id]; + const project = entry?.currentActiveProject?.trim?.() || ""; + if (project) { + const entryUrl = normalizeJpdUrl(entry.jpdUrl); + if (wantUrl === "" || entryUrl === wantUrl) return project; + } + } + + if (wantUrl) { + const ids = Object.keys(servers).sort(); + for (const sid of ids) { + const entry = servers[sid]; + const project = entry?.currentActiveProject?.trim?.() || ""; + if (project && normalizeJpdUrl(entry.jpdUrl) === wantUrl) return project; + } + } + return ""; +} + +/** + * Parse `jf config show --format=json` into a server list. + * @param {string} stdout + * @returns {{ serverId: string, jpdUrl: string, isDefault: boolean }[]} + */ +export function parseJfConfigShowJson(stdout) { + if (typeof stdout !== "string" || !stdout.trim()) return []; + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + return []; + } + const list = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.servers) + ? parsed.servers + : parsed + ? [parsed] + : []; + /** @type {{ serverId: string, jpdUrl: string, isDefault: boolean }[]} */ + const out = []; + for (const s of list) { + if (!isPlainObject(s)) continue; + const serverId = String(s.serverId ?? "").trim(); + if (!serverId) continue; + const jpdUrl = normalizeJpdUrl( + s.url || s.Url || s.artifactoryUrl || s.platformUrl || "", + ); + out.push({ + serverId, + jpdUrl, + isDefault: Boolean(s.isDefault), + }); + } + return out; +} + +/** + * Exactly one server, or the isDefault entry. Otherwise { error }. + * @param {{ serverId: string, jpdUrl: string, isDefault: boolean }[]} servers + * @returns {{ serverId: string, jpdUrl: string } | { error: "missing" | "no_default" }} + */ +export function pickDefaultJfCliServer(servers) { + const list = servers ?? []; + if (list.length === 0) return { error: "missing" }; + if (list.length === 1) { + return { serverId: list[0].serverId, jpdUrl: list[0].jpdUrl }; + } + const def = list.find((s) => s.isDefault); + if (def) return { serverId: def.serverId, jpdUrl: def.jpdUrl }; + return { error: "no_default" }; +} + +/** + * @param {{ + * env?: NodeJS.ProcessEnv, + * spawnSyncFn?: typeof spawnSync, + * }} [opts] + * @returns {{ serverId: string, jpdUrl: string }[]} + */ +export function listJfCliServers(opts = {}) { + const env = opts.env ?? process.env; + const spawnSyncFn = opts.spawnSyncFn ?? spawnSync; + let res; + try { + res = spawnSyncFn("jf", ["config", "show", "--format=json"], { + encoding: "utf8", + timeout: 5_000, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + return []; + } + if (res?.error || res.status !== 0) return []; + return parseJfConfigShowJson(res.stdout ?? ""); +} + +/** + * Resolve server for gate + rewrite. Always expects a concrete server id + * for plugin MCP (Shay): hint → jf config (one / isDefault) → env. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {{ + * serverIdHint?: string, + * spawnSyncFn?: typeof spawnSync, + * }} [opts] + * @returns {{ + * serverId: string, + * jpdUrl: string, + * } | { + * error: "missing" | "no_default", + * }} + */ +export function resolveRewriteServer(env = process.env, opts = {}) { + const servers = listJfCliServers({ + env, + spawnSyncFn: opts.spawnSyncFn, + }); + + const hint = opts.serverIdHint?.trim(); + if (hint) { + const match = servers.find((s) => s.serverId === hint); + return { + serverId: hint, + jpdUrl: match?.jpdUrl ?? "", + }; + } + + const picked = pickDefaultJfCliServer(servers); + if (!("error" in picked)) return picked; + + const fromEnv = env.JF_SERVER?.trim() || env.JFROG_SERVER_ID?.trim() || ""; + if (fromEnv) { + const match = servers.find((s) => s.serverId === fromEnv); + return { serverId: fromEnv, jpdUrl: match?.jpdUrl ?? "" }; + } + return picked.error === "no_default" + ? { error: "no_default" } + : { error: "missing" }; +} + +/** + * Resolve JFrog project key: env → setup.json (AG-compatible) → "". + * @param {NodeJS.ProcessEnv} [env] + * @param {{ + * serverId?: string, + * jpdUrl?: string, + * readFileSyncFn?: typeof readFileSync, + * setupPath?: string, + * }} [opts] + * @returns {string} + */ +export function resolveRewriteProject(env = process.env, opts = {}) { + const fromEnv = env.JF_PROJECT?.trim() || env.JFROG_PROJECT?.trim() || ""; + if (fromEnv) return fromEnv; + return activeProjectFromSetupFile(opts.serverId ?? "", opts.jpdUrl ?? "", { + env, + readFileSyncFn: opts.readFileSyncFn, + setupPath: opts.setupPath, + }); +} + +/** + * @deprecated Use resolveRewriteServer. Kept for callers that only need the id. + * @param {NodeJS.ProcessEnv} [env] + * @param {{ serverIdHint?: string, spawnSyncFn?: typeof spawnSync }} [opts] + * @returns {string} + */ +export function resolveRewriteServerId(env = process.env, opts = {}) { + const resolved = resolveRewriteServer(env, opts); + if ("error" in resolved) return ""; + return resolved.serverId; +} + +/** + * @param {NodeJS.Platform} [platform] + */ +export function resolveNpxCommand(platform = process.platform) { + return platform === "win32" ? "npx.cmd" : "npx"; +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {NodeJS.Platform} [platform] + * @param {{ local?: boolean }} [opts] + */ +export function buildNpxSpawnOptions( + env, + platform = process.platform, + opts = {}, +) { + const isWin = platform === "win32"; + const useShell = isWin && !opts.local; + return { + stdio: /** @type {const} */ (["pipe", "pipe", "pipe"]), + env, + // Pin cmd.exe — shell: true would honor ComSpec (e.g. PowerShell). + shell: useShell ? "cmd.exe" : false, + detached: !isWin, + }; +} + +/** + * Safe grammar for JF project keys / server IDs passed on a Windows cmd.exe + * command line (and as a general injection guard on all platforms). + * @param {string} value + * @returns {boolean} + */ +export function isSafeRewriteIdentifier(value) { + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(String(value ?? "")); +} + +/** + * @param {string} value + * @param {string} label + * @returns {string} + * @throws {Error} when value is not a safe identifier + */ +export function assertSafeRewriteIdentifier(value, label = "identifier") { + const trimmed = String(value ?? "").trim(); + if (!isSafeRewriteIdentifier(trimmed)) { + throw new Error( + `rewrite-mcp-json ${label} must be a safe identifier (A-Za-z0-9._-): ${JSON.stringify(trimmed)}`, + ); + } + return trimmed; +} + +/** + * Quote a single argv token for Node spawn under shell: "cmd.exe". + * Uses cmd.exe rules: wrap in ", double embedded quotes, escape % as %%. + * CRT-style backslash-escaping is NOT safe under cmd.exe (a quote can break + * out and leave metacharacters like & executable). + * @param {string} arg + * @returns {string} + * @throws {Error} when the arg contains CR/LF + */ +export function quoteWindowsArg(arg) { + const value = String(arg ?? ""); + if (/[\r\n]/.test(value)) { + throw new Error("Windows spawn arg must not contain CR/LF"); + } + // Neutralize %VAR% expansion, then double any embedded quotes for cmd.exe. + const escaped = value.replace(/%/g, "%%").replace(/"/g, '""'); + return `"${escaped}"`; +} + +/** + * @param {string[]} args + * @param {NodeJS.Platform} [platform] + * @returns {string[]} + */ +export function quoteSpawnArgs(args, platform = process.platform) { + return platform === "win32" ? args.map(quoteWindowsArg) : args; +} + +/** + * @param {{ pid?: number, kill?: (signal?: string) => boolean }} child + * @param {{ + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * spawnFn?: typeof spawn, + * graceMs?: number, + * isAlive?: () => boolean, + * waitForExit?: Promise, + * }} [opts] + * @returns {Promise} + */ +export async function killRewriteChildTree(child, opts = {}) { + const platform = opts.platform ?? process.platform; + const killFn = opts.killFn ?? process.kill; + const spawnFn = opts.spawnFn ?? spawn; + const graceMs = opts.graceMs ?? DEFAULT_KILL_GRACE_MS; + const isAlive = opts.isAlive ?? (() => true); + + const signalChild = (signal) => { + try { + child?.kill?.(signal); + } catch { + // Already gone. + } + }; + + const signalTree = (signal) => { + if (platform === "win32") { + if (child?.pid) { + try { + const killer = spawnFn( + "taskkill", + ["/pid", String(child.pid), "/T", "/F"], + { stdio: "ignore" }, + ); + killer?.on?.("error", () => {}); + return; + } catch { + // fall through + } + } + signalChild(signal); + return; + } + + if (child?.pid) { + try { + killFn(-child.pid, signal); + return; + } catch { + // Fall through to child.kill when the group is already gone. + } + } + signalChild(signal); + }; + + signalTree("SIGTERM"); + + if (graceMs <= 0 || !isAlive()) return; + await waitForExitOrTimeout(opts.waitForExit, graceMs); + if (!isAlive()) return; + + log.warn("rewrite child ignored SIGTERM; escalating to SIGKILL", { + graceMs, + }); + signalTree("SIGKILL"); + + // Wait for confirmed exit so callers do not process.exit while AG is + // mid-write (truncated mcp.json). Cap at the same grace window. + if (graceMs <= 0 || !isAlive()) return; + await waitForExitOrTimeout(opts.waitForExit, graceMs); +} + +/** + * @param {Promise | undefined} exited + * @param {number} graceMs + */ +function waitForExitOrTimeout(exited, graceMs) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, graceMs); + exited?.then( + () => { + clearTimeout(timer); + resolve(undefined); + }, + () => { + clearTimeout(timer); + resolve(undefined); + }, + ); + }); +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function resolveAgentGuardNpmRegistry(env = process.env) { + const fromEnv = env.JFROG_AGENT_GUARD_REPO?.trim(); + return fromEnv || DEFAULT_AGENT_GUARD_NPM_REGISTRY; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function resolveAgentGuardSpec(env = process.env) { + const version = + env.JFROG_AGENT_GUARD_VERSION?.trim() || DEFAULT_AGENT_GUARD_VERSION; + return `${AGENT_GUARD_PACKAGE}@${version}`; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {string | undefined} + */ +export function resolveAgentGuardBin(env = process.env) { + return env[AGENT_GUARD_BIN_ENV]?.trim() || undefined; +} + +/** + * @param {{ + * paths: string[], + * project: string, + * serverId: string, + * agSpec: string, + * statSyncFn?: typeof statSync, + * }} opts + * @returns {string} + */ +export function computeRewriteFingerprint(opts) { + const statFn = opts.statSyncFn ?? statSync; + const pathParts = [...(opts.paths ?? [])].sort().map((p) => { + try { + const st = statFn(p); + return `${p}:${st.mtimeMs}:${st.size}`; + } catch { + return `${p}:missing`; + } + }); + const payload = JSON.stringify({ + paths: pathParts, + project: opts.project, + serverId: opts.serverId, + agSpec: opts.agSpec, + }); + return createHash("sha256").update(payload).digest("hex"); +} + +/** + * @param {string} markerPath + * @param {{ readFileSyncFn?: typeof readFileSync }} [opts] + * @returns {string} + */ +export function readRewriteMarker(markerPath, opts = {}) { + const readFn = opts.readFileSyncFn ?? readFileSync; + try { + return String(readFn(markerPath, "utf8")).trim(); + } catch { + return ""; + } +} + +/** + * @param {string} markerPath + * @param {string} fingerprint + * @param {{ writeFileSyncFn?: typeof writeFileSync, mkdirSyncFn?: typeof mkdirSync }} [opts] + */ +export function writeRewriteMarker(markerPath, fingerprint, opts = {}) { + const writeFn = opts.writeFileSyncFn ?? writeFileSync; + const mkdirFn = opts.mkdirSyncFn ?? mkdirSync; + mkdirFn(path.dirname(markerPath), { recursive: true }); + writeFn(markerPath, `${fingerprint}\n`, "utf8"); +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * }} opts + * @returns {string[]} + * @throws {Error} when project/server missing or paths are empty + */ +export function buildAgentGuardRewriteArgs(opts) { + const env = opts.env ?? process.env; + const paths = opts.paths ?? []; + if (paths.length === 0) { + throw new Error("rewrite-mcp-json requires at least one mcp.json path"); + } + const project = opts.project?.trim() || resolveRewriteProject(env, {}); + if (!project) { + throw new Error("rewrite-mcp-json requires --project (or JF_PROJECT)"); + } + assertSafeRewriteIdentifier(project, "project"); + + const args = ["--rewrite-mcp-json", ...paths, "--project", project]; + + const server = + opts.serverId !== undefined + ? opts.serverId.trim() + : resolveRewriteServerId(env); + if (!server) { + throw new Error("rewrite-mcp-json requires --server (or JF_SERVER)"); + } + assertSafeRewriteIdentifier(server, "server"); + args.push("--server", server); + + const agentGuardRegistry = env.JFROG_AGENT_GUARD_REPO?.trim(); + if (agentGuardRegistry) { + args.push("--registry", agentGuardRegistry); + } + + for (const root of opts.allowRoots ?? []) { + if (root) args.push("--allow-root", root); + } + + args.push("--format", "json"); + return args; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * }} opts + * @returns {string[]} + */ +export function buildNpxArgs(opts) { + const env = opts.env ?? process.env; + return [ + "--yes", + "--registry", + resolveAgentGuardNpmRegistry(env), + resolveAgentGuardSpec(env), + ...buildAgentGuardRewriteArgs(opts), + ]; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * platform?: NodeJS.Platform, + * }} opts + * @returns {{ command: string, args: string[], local: boolean }} + */ +export function resolveAgentGuardCommand(opts) { + const env = opts.env ?? process.env; + const platform = opts.platform ?? process.platform; + const bin = resolveAgentGuardBin(env); + if (bin) { + return { + command: bin, + args: buildAgentGuardRewriteArgs(opts), + local: true, + }; + } + return { + command: resolveNpxCommand(platform), + args: buildNpxArgs(opts), + local: false, + }; +} + +/** + * Spawn Agent Guard `--rewrite-mcp-json`. AG writes files; stdout is JSON + * summary when `--format json` is passed. + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * spawnFn?: typeof spawn, + * env?: NodeJS.ProcessEnv, + * timeoutMs?: number, + * graceMs?: number, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * }} opts + * @returns {Promise<{ code: number, stdout: string, stderr: string }>} + */ +export function runAgentGuardRewriteMcpJson(opts) { + const spawnFn = opts.spawnFn ?? spawn; + const env = opts.env ?? process.env; + const timeoutMs = + opts.timeoutMs === undefined ? DEFAULT_REWRITE_TIMEOUT_MS : opts.timeoutMs; + const platform = opts.platform ?? process.platform; + + let command; + let args; + let spawnOpts; + try { + const resolved = resolveAgentGuardCommand({ + paths: opts.paths, + project: opts.project, + serverId: opts.serverId, + allowRoots: opts.allowRoots, + env, + platform, + }); + command = resolved.command; + spawnOpts = buildNpxSpawnOptions(env, platform, { local: resolved.local }); + args = spawnOpts.shell + ? quoteSpawnArgs(resolved.args, platform) + : resolved.args; + } catch (err) { + return Promise.resolve({ + code: 1, + stdout: "", + stderr: err?.message ?? String(err), + }); + } + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let exited = false; + let timedOut = false; + let markExited = () => {}; + const exitedPromise = new Promise((r) => { + markExited = r; + }); + /** @type {ReturnType | undefined} */ + let timer; + const finish = (result) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(result); + }; + + let child; + try { + child = spawnFn(command, args, spawnOpts); + } catch (err) { + finish({ + code: 1, + stdout: "", + stderr: err?.message ?? String(err), + }); + return; + } + + child.stdout?.setEncoding?.("utf8"); + child.stderr?.setEncoding?.("utf8"); + child.stdout?.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (err) => { + exited = true; + markExited(); + finish({ + code: 1, + stdout, + stderr: err?.message ?? String(err), + }); + }); + child.on("close", (code) => { + exited = true; + markExited(); + if (timedOut) return; + finish({ code: code ?? 1, stdout, stderr }); + }); + + child.stdin?.on?.("error", () => {}); + try { + child.stdin?.end(); + } catch { + // Child may already have exited. + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + timedOut = true; + const finishTimedOut = () => { + finish({ + code: 1, + stdout, + stderr: `${stderr ? `${stderr.trim()}\n` : ""}rewrite timed out after ${timeoutMs}ms`, + }); + }; + killRewriteChildTree(child, { + platform, + killFn: opts.killFn, + spawnFn, + graceMs: opts.graceMs, + isAlive: () => !exited, + waitForExit: exitedPromise, + }).then(finishTimedOut, finishTimedOut); + }, timeoutMs); + } + }); +} + +/** + * @param {string} text + * @returns {Record | null} + */ +function tryParseJsonObject(text) { + try { + const parsed = JSON.parse(text); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Parse AG `--format json` summary. Tolerates leading npx noise by trying the + * last non-empty line, then the last `{...}` slice. + * @param {string} raw + * @returns {{ scanned?: number, rewritten?: number, files?: string[], errors?: string[], dryRun?: boolean } | null} + */ +export function parseRewriteMcpJsonResult(raw) { + if (typeof raw !== "string" || !raw.trim()) return null; + const trimmed = raw.trim(); + const direct = tryParseJsonObject(trimmed); + if (direct) return direct; + + const lines = trimmed + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + const parsed = tryParseJsonObject(lines[i]); + if (parsed) return parsed; + } + + const start = trimmed.lastIndexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + return tryParseJsonObject(trimmed.slice(start, end + 1)); + } + return null; +} + +/** + * Strip userinfo from URLs before logging. + * @param {string} text + * @returns {string} + */ +export function redactUrlCredentials(text) { + return String(text ?? "").replace( + /([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, + "$1***@", + ); +} + +/** + * Orchestration: kill switch → server/project → discover → skip-if-current → + * Step 0 gate → rewrite. Server id is resolved once and reused for both the + * gate and AG `--server` (always passed). Returns a structured result; exitCode + * is 0 unless JF_AGENT_REWRITE_MCP_JSON_STRICT=1 and outcome is failed_*. + * + * @param {{ + * discover: () => string[] | Promise, + * allowRoots?: string[] | ((paths: string[]) => string[]), + * env?: NodeJS.ProcessEnv, + * spawnFn?: typeof spawn, + * spawnSyncFn?: typeof spawnSync, + * timeoutMs?: number, + * graceMs?: number, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * runAgentGuardCheckFn?: typeof runAgentGuardCheck, + * readFileSyncFn?: typeof readFileSync, + * writeFileSyncFn?: typeof writeFileSync, + * mkdirSyncFn?: typeof mkdirSync, + * statSyncFn?: typeof statSync, + * serverIdHint?: string, + * markerPath?: string, + * setupPath?: string, + * }} opts + * @returns {Promise<{ exitCode: number, outcome: string, reason: string }>} + */ +export async function runRewriteMcpJsonPipeline(opts) { + const env = opts.env ?? process.env; + const checkFn = opts.runAgentGuardCheckFn ?? runAgentGuardCheck; + + if (isRewriteDisabled(env)) { + log.info("rewrite disabled via env", { env: DISABLE_ENV }); + return pipelineResult(OUTCOME.DISABLED, DISABLE_ENV, env); + } + + const serverResolved = resolveRewriteServer(env, { + serverIdHint: opts.serverIdHint, + spawnSyncFn: opts.spawnSyncFn, + }); + if ("error" in serverResolved) { + const reason = + serverResolved.error === "no_default" + ? "multiple jf config servers and none isDefault" + : "no jf config server / JF_SERVER"; + log.info("rewrite skipped; missing server", { reason }); + return pipelineResult(OUTCOME.SKIPPED_NO_SERVER, reason, env); + } + const { serverId, jpdUrl } = serverResolved; + if (!isSafeRewriteIdentifier(serverId)) { + log.info("rewrite skipped; unsafe server id", {}); + return pipelineResult( + OUTCOME.SKIPPED_UNSAFE_SERVER, + "unsafe server id", + env, + ); + } + + const project = resolveRewriteProject(env, { + serverId, + jpdUrl, + readFileSyncFn: opts.readFileSyncFn, + setupPath: opts.setupPath, + }); + if (!project) { + log.info("rewrite skipped; missing project", {}); + return pipelineResult( + OUTCOME.SKIPPED_NO_PROJECT, + "missing JF_PROJECT / setup.json currentActiveProject", + env, + ); + } + if (!isSafeRewriteIdentifier(project)) { + log.info("rewrite skipped; unsafe JF_PROJECT", {}); + return pipelineResult( + OUTCOME.SKIPPED_UNSAFE_PROJECT, + "unsafe project", + env, + ); + } + + let paths; + try { + paths = await opts.discover(); + } catch (err) { + const reason = err?.message ?? String(err); + log.error("discover failed; soft no-op", { error: reason }); + return pipelineResult(OUTCOME.FAILED_DISCOVER, reason, env); + } + + if (!Array.isArray(paths) || paths.length === 0) { + log.info("no mcp.json files found; skip rewrite"); + return pipelineResult(OUTCOME.SKIPPED_NO_PATHS, "no mcp.json", env); + } + + const agSpec = resolveAgentGuardSpec(env); + const fingerprint = computeRewriteFingerprint({ + paths, + project, + serverId, + agSpec, + statSyncFn: opts.statSyncFn, + }); + const markerPath = opts.markerPath ?? defaultRewriteMarkerPath(env); + if ( + !isRewriteForced(env) && + readRewriteMarker(markerPath, { readFileSyncFn: opts.readFileSyncFn }) === + fingerprint + ) { + log.info("rewrite skipped; already current", { markerPath }); + return pipelineResult(OUTCOME.SKIPPED_CURRENT, markerPath, env); + } + + let gate; + try { + gate = await checkFn({ + serverId, + env, + }); + } catch (err) { + const reason = redactUrlCredentials(err?.message ?? String(err)); + log.error("agent-guard check threw; soft no-op", { error: reason }); + return pipelineResult(OUTCOME.FAILED_GATE, reason, env); + } + if (gate.code !== EXIT_ENABLED) { + const reason = redactUrlCredentials(gate.reason ?? ""); + log.info("agent-guard check blocked rewrite; soft no-op", { + code: gate.code, + reason, + }); + return pipelineResult(OUTCOME.SKIPPED_GATE, reason, env); + } + + let allowRoots; + try { + allowRoots = + typeof opts.allowRoots === "function" + ? opts.allowRoots(paths) + : (opts.allowRoots ?? []); + } catch (err) { + const reason = redactUrlCredentials(err?.message ?? String(err)); + log.error("allowRoots failed; soft no-op", { error: reason }); + return pipelineResult(OUTCOME.FAILED_ALLOW_ROOTS, reason, env); + } + + log.info("rewrite-mcp-json targets", { + count: paths.length, + allowRoots: allowRoots.length, + outcome: "rewrite", + }); + + const budgetMs = + opts.timeoutMs === undefined ? DEFAULT_REWRITE_TIMEOUT_MS : opts.timeoutMs; + const startedAtMs = Date.now(); + const result = await runAgentGuardRewriteMcpJson({ + paths, + project, + serverId, + allowRoots, + env, + spawnFn: opts.spawnFn, + timeoutMs: budgetMs, + graceMs: opts.graceMs, + platform: opts.platform, + killFn: opts.killFn, + }); + const durMs = Date.now() - startedAtMs; + + if (result.code !== 0) { + const reason = redactUrlCredentials((result.stderr || "").trim()).slice( + 0, + 500, + ); + log.error("rewrite-mcp-json failed", { + code: result.code, + stderr: reason, + durMs, + outcome: OUTCOME.FAILED_SPAWN, + }); + return pipelineResult(OUTCOME.FAILED_SPAWN, reason, env); + } + + const postFingerprint = computeRewriteFingerprint({ + paths, + project, + serverId, + agSpec, + statSyncFn: opts.statSyncFn, + }); + try { + writeRewriteMarker(markerPath, postFingerprint, { + writeFileSyncFn: opts.writeFileSyncFn, + mkdirSyncFn: opts.mkdirSyncFn, + }); + } catch (err) { + log.warn("rewrite marker write failed", { + markerPath, + error: err?.message ?? String(err), + }); + } + + const summary = parseRewriteMcpJsonResult(result.stdout); + if (summary) { + log.info("rewrite-mcp-json ok", { + scanned: summary.scanned, + rewritten: summary.rewritten, + errors: summary.errors?.length ?? 0, + durMs, + outcome: OUTCOME.REWRITTEN, + }); + } else { + log.info("rewrite-mcp-json ok; no JSON summary", { + durMs, + outcome: OUTCOME.REWRITTEN, + }); + } + + return pipelineResult(OUTCOME.REWRITTEN, "", env); +} diff --git a/plugin/modules/core/scaffold-fingerprint.mjs b/plugin/modules/core/scaffold-fingerprint.mjs new file mode 100644 index 0000000..7ac2388 --- /dev/null +++ b/plugin/modules/core/scaffold-fingerprint.mjs @@ -0,0 +1,96 @@ +// Scaffold fingerprint — detect never-configured agents-conf.json. +// +// Hash the user's config (canonical JSON) against every historically shipped +// template. Untouched scaffold ⇒ eligible for onboarding; any deviation ⇒ +// treat as deliberate (admin/MDM/hand-edit) and stay silent when +// onboardingPrompt is absent. + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { agentsConfigPath } from "./agents-config.mjs"; + +const PLUGIN_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const FINGERPRINTS_PATH = path.join( + PLUGIN_ROOT, + "assets", + "agents-conf-fingerprints.json", +); + +const TEMPLATE_PATH = path.join( + PLUGIN_ROOT, + "assets", + "agents-default-conf.json", +); + +/** Deterministic JSON for hashing (sorted keys, no whitespace). */ +export function canonicalizeJson(value) { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((v) => canonicalizeJson(v)).join(",")}]`; + } + const keys = Object.keys(value).sort(); + return `{${keys + .map((k) => `${JSON.stringify(k)}:${canonicalizeJson(value[k])}`) + .join(",")}}`; +} + +export function sha256Canonical(value) { + return createHash("sha256").update(canonicalizeJson(value)).digest("hex"); +} + +function loadFingerprintSet() { + const set = new Set(); + try { + const raw = JSON.parse(readFileSync(FINGERPRINTS_PATH, "utf8")); + for (const entry of raw?.fingerprints ?? []) { + if (typeof entry?.sha256 === "string" && entry.sha256) { + set.add(entry.sha256); + } + } + } catch { + // fall through — still register current template below + } + try { + const tmpl = JSON.parse(readFileSync(TEMPLATE_PATH, "utf8")); + set.add(sha256Canonical(tmpl)); + } catch { + // ignore + } + return set; +} + +/** + * True when agents-conf.json is missing or matches a shipped template hash. + * @param {string} [configPath] + */ +export function isNeverConfiguredScaffold(configPath = agentsConfigPath()) { + if (!existsSync(configPath)) return true; + let parsed; + try { + parsed = JSON.parse(readFileSync(configPath, "utf8")); + } catch { + return false; + } + if (!parsed || typeof parsed !== "object") return false; + const known = loadFingerprintSet(); + return known.has(sha256Canonical(parsed)); +} + +/** Guard for tests: current shipped template must be registered. */ +export function currentTemplateFingerprint() { + const tmpl = JSON.parse(readFileSync(TEMPLATE_PATH, "utf8")); + return sha256Canonical(tmpl); +} + +export function registeredFingerprints() { + return [...loadFingerprintSet()]; +} diff --git a/plugin/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md b/plugin/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md new file mode 100644 index 0000000..4467ecb --- /dev/null +++ b/plugin/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md @@ -0,0 +1,168 @@ +# Consent Enable — Agent Package Resolution + +The user agreed to enable Agent Package Resolution. Follow these steps in order. +Do not invent repo keys. Never list the Artifactory catalog, all virtuals, or +wildcard names (`*-virtual`, `**`). Those responses can be thousands of +rows and will flood this chat. + +This procedure is reached via the soft-bridge Yes/No offer, injected the same +way on **Cursor**, **Claude Code**, and **VS Code Copilot**. + +## 1. Ask which repository / package types to configure + +This procedure is the **only** place the types question is asked — the injected +nudge deliberately does not ask it. If you already asked, do not ask again; reuse +their answer. + +Before binding repos or enable, **ask the user which types they want to govern**, +as a plain chat question with the supported types inline: + +> Which package types should route through Artifactory? Supported: `npm`, +> `pypi`, `maven`, `gradle`, `go`, `docker`, `helm`, `nuget`. Reply with the +> ones you want (e.g. "maven and pypi"). + +Ask this as **free text**. Do **not** put the eight types into a structured +multiple-choice / options picker — those pickers cap at four options and the +call will fail validation. + +They may choose one, several, or all. Do **not** assume “all types.” Do **not** +enable a type they did not pick. If they are unsure, briefly explain that only +chosen types get Artifactory routing; others stay untouched. + +Wait for their answer. Remember the chosen set as `CHOSEN_TYPES`. + +## 2. Prerequisites + +- Ensure `jf` is installed and on PATH. +- Ensure a JFrog server is configured (`jf config show`). Prefer access token or + username + password / API key auth. +- If setup is needed, follow the base `jfrog` skill login flow. Do **not** run + `jf setup` until after enable + auto-setup below. + +## 3. Bind one type at a time (base `jfrog` skill) + +There is **no** discovery skill and **no** `configure.mjs discover` command. +Use the base **`jfrog` skill** only for **bounded** lookups (MCP / `jf` / +`jf api` as the skill directs). Do **not** invent keys. + +If `CHOSEN_TYPES` has more than one type, configure them **one type at a time**. +Do not ask for project/repo for every type in one message. Do not start type +N+1 until type N is bound, skipped, or the user declines that type. + +For the current type, ask as **free text** (not a project picker, not “list +projects”): + +> For ``, what is the Artifactory **project key** or **repository** +> key/name? Either is enough. If you do not know either, say so. + +Resolve that type through **exactly one** path: + +- **Repository given** (alone or with a project) → verify only that key. + Ignore the project for lookup. +- **Project given, no repository** → one filtered call only: that exact + project + `type=virtual` + this `packageType`. Never fetch the full project + catalog or an unfiltered platform catalog. + - **Query failed** (auth/network/skill error) → say what failed, fix the + cause (usually `jf` auth), and retry the same filtered call. Do not invent + a key. Do not treat failure as “none found.” + - **0** matches → say none in that project; ask for another project or an + exact repository. Do not bind. + - **1** match → use that key. Do not ask. + - **2–10** matches → show **name and key** (if the API exposes only `key`, + use the key as the name too) and ask which to use. + - **More than 10** → do **not** list, quote, or keep the extra rows. Ask for + the exact repository name. +- **Neither given** → **exact-key fallback**. Point-lookup only + `-virtual`, `-default`, then `-release` (for example + `npm-virtual`, `npm-default`, `npm-release`). Verify each hit. Do not search + or glob. + - **0** verified hits → ask again for a project or repository for **this + type only**. Suggest they contact their Artifactory admin if they have + neither. Do not bind. + - **1** verified hit → use that key. Do not ask. + - **2–3** verified hits → show only those keys (name and key) and ask the + user to pick one. + +**Forbidden** (every type, every turn): unfiltered `list repositories`, +platform-wide virtual listing, `*-virtual`, `**`, paginating the catalog, +or dumping a large API payload into chat. A user who says “I don’t know” +gets exact-key fallback — never a catalog dump. + +Verify every auto-bound, user-confirmed, or pasted key before binding: + +```bash +node "{{CONFIGURE_COMMAND}}" verify-repo --type '' --repo '' +``` + +`verify-repo` fails closed: it confirms the key is a **virtual** repo whose +`packageType` matches. If it fails, ask for a different key — do not bind it. +Verify every key (unique auto-binds included — cheap defense-in-depth). + +Then move to the next chosen type. Collect resolved keys into a +`type → repoKey` map. If the map is empty (every type unresolved), stop and +explain; do **not** call enable. + +## 4. Enable + auto-setup (no second ask) + +After the verified map has at least one binding, enable **and** turn on +zero-touch auto-setup for those types. Auto-setup is part of Consent Enable — +**do not** ask a separate “want auto-setup?” question. + +`enable` **replaces** `defaultGlobalRepos` with the JSON object you pass. It +does **not** merge. Re-include every type that should stay bound — this +session’s map **plus** any already-bound keys from `configure.mjs status` +(or the current `defaultGlobalRepos`). Same for `auto-setup`: it **replaces** +`autoSetup`; pass every type that should stay in that list (typically the +same keys). + +```bash +node "{{CONFIGURE_COMMAND}}" enable --repos '' +node "{{CONFIGURE_COMMAND}}" auto-setup --types '' +``` + +Example: + +```bash +node "{{CONFIGURE_COMMAND}}" enable --repos '{"maven":"libs-release-virtual","pypi":"pypi"}' +node "{{CONFIGURE_COMMAND}}" auto-setup --types '["maven","pypi"]' +``` + +`enable` writes `enabled: true` and **only** those `defaultGlobalRepos`. It +**re-verifies** each key (fail-closed). The nudge's offerable-types list is +computed fresh on the next SessionStart from `defaultGlobalRepos` + the decline +cache — nothing to re-sync, it just shrinks as types get bound. `auto-setup` +opts those types into user-global `jf setup`. + +Enable **only** types that bound. Unbound chosen types stay off; say so. If +the bound map is empty, do not call enable. + +## 5. Load routing + verify auto-setup + +Run print-policy **synchronously**. Its stdout **is** the Package Resolution +table for this chat (Decision order + URL table + setup status). Follow that +table for the rest of this session. + +```bash +JFROG_EAGER_SETUP_SYNC=1 node "{{PRINT_POLICY_COMMAND}}" +``` + +Wait until bound types show as **already set up**. Do **not** install while +the note says `setting up in the background`. If it still does, run +print-policy again and read the new note. + +Do **not** install a test package. Do **not** read npmrc / pip.conf as extra +proof. + +- Type **already set up** → later installs for that type use the normal + package-manager command. **No** `--registry`, `--index-url`, `GOPROXY=…`, + or other rewrite flags. +- Type **pending / failed / conflict** → follow the existing conflict/retry + path in the printed note. That type is not ready. Never use rewrite flags + as a fallback. +- Do **not** claim overall success unless every bound type set up. + +## 6. New chat + +**After** enable, auto-setup, and the sync print-policy check, tell the user +that opening a **new chat** (or reloading the IDE) picks up the updated +hooks cleanly. Routing already works in this session after `print-policy`. diff --git a/plugin/modules/package-resolution/onboarding/session-start-nudge.md b/plugin/modules/package-resolution/onboarding/session-start-nudge.md new file mode 100644 index 0000000..ae1cffb --- /dev/null +++ b/plugin/modules/package-resolution/onboarding/session-start-nudge.md @@ -0,0 +1,25 @@ +# Agent Package Resolution — install-time offer + +Do not mention this proactively or summarize it up front, and do not narrate +a status check ("I'll check whether this was already asked...") — this text +only appears when at least one of these types is still open, so that check +is already done. Wait until the user or agent is actually about to run a +package-manager install for one of ({{SUPPORTED_TYPES}}) — e.g. +`npm install`, `pip install`, Maven/Gradle deps, `go get`, Docker/Helm/NuGet. + +The moment that happens, and only for the specific type **T** matching that +install, the Yes/No ask below must be the first thing in your reply — before +any other sentence. Ask which package/version you need in the same reply if +you must, but the ask below comes first. + +> Agent Package Resolution can route **T** installs through your Artifactory +> **virtual** repositories instead of public registries. Want to set that up +> for **T** now? Details: {{ADMIN_GUIDE_URL}} + +- **Yes** → run `node "{{CONFIGURE_COMMAND}}" onboarding-procedure` +- **No** → run `node "{{CONFIGURE_COMMAND}}" dismiss --type ` (use the APR + type, e.g. `pypi`, `npm`) + +Ask at most once per type per conversation. Never ask on unrelated chats. +Don't re-ask a declined or already-bound type — other types may still be +offered later. diff --git a/plugin/modules/package-resolution/scripts/apr-heartbeat.mjs b/plugin/modules/package-resolution/scripts/apr-heartbeat.mjs new file mode 100644 index 0000000..096f58f --- /dev/null +++ b/plugin/modules/package-resolution/scripts/apr-heartbeat.mjs @@ -0,0 +1,349 @@ +// Daily APR session heartbeat — best-effort `jf rt ping` so Coralogix still +// sees hook-driven traffic when eager `jf setup` is skipped (steady state). +// +// Gated to routing-mode sessionStart (caller). At most once per 24h per +// serverId via ~/.jfrog/skills-cache/apr-heartbeat-v1.json, with an exclusive +// per-server lock file to reduce cross-process stampedes. Never throws — +// heartbeat must not break injection. + +import { spawn } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { createLogger } from "../../core/logger.mjs"; +import { getPlatformIdentity } from "../../core/jf-identity.mjs"; +import { envWithHookUserAgent } from "../../core/jf-user-agent.mjs"; + +const log = createLogger("apr-heartbeat"); + +const RECEIPT_SCHEMA_VERSION = 1; +const HEARTBEAT_TTL_MS = 24 * 60 * 60 * 1000; +const LOCK_STALE_MS = 60 * 1000; + +/** @returns {string} `~/.jfrog/skills-cache` */ +function cacheDir() { + return path.join(homedir(), ".jfrog", "skills-cache"); +} + +/** @returns {string} path to the heartbeat receipt */ +export function heartbeatReceiptPath() { + return path.join(cacheDir(), "apr-heartbeat-v1.json"); +} + +/** @param {string} serverId */ +export function heartbeatLockPath(serverId) { + const safe = String(serverId).replace(/[^a-zA-Z0-9._-]+/g, "_"); + return path.join(cacheDir(), `apr-heartbeat-${safe}.lock`); +} + +/** @returns {{ schemaVersion: number, servers: Record }} */ +function emptyReceipt() { + return { schemaVersion: RECEIPT_SCHEMA_VERSION, servers: {} }; +} + +/** + * Normalize on-disk JSON; drop unexpected schema / junk. + * @param {unknown} data + * @returns {{ schemaVersion: number, servers: Record }} + */ +export function normalizeHeartbeatReceipt(data) { + if ( + !data || + typeof data !== "object" || + data.schemaVersion !== RECEIPT_SCHEMA_VERSION + ) { + return emptyReceipt(); + } + const servers = {}; + if (data.servers && typeof data.servers === "object") { + for (const [serverId, raw] of Object.entries(data.servers)) { + if (!raw || typeof raw !== "object") continue; + if (typeof raw.lastPingAt !== "string" || !raw.lastPingAt) continue; + servers[serverId] = { lastPingAt: raw.lastPingAt }; + } + } + return { schemaVersion: RECEIPT_SCHEMA_VERSION, servers }; +} + +/** + * @param {string} [file] + * @returns {{ schemaVersion: number, servers: Record }} + */ +export function readHeartbeatReceipt(file = heartbeatReceiptPath()) { + try { + if (!existsSync(file)) return emptyReceipt(); + return normalizeHeartbeatReceipt(JSON.parse(readFileSync(file, "utf8"))); + } catch (err) { + log.debug("heartbeat receipt read failed", { + error: err?.message ?? String(err), + }); + return emptyReceipt(); + } +} + +/** + * @param {{ schemaVersion: number, servers: Record }} receipt + * @param {string} [file] + */ +export function writeHeartbeatReceipt(receipt, file = heartbeatReceiptPath()) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, "utf8"); +} + +/** + * Whether a ping should fire for this serverId (no receipt or stale). + * @param {{ servers?: Record } | null} receipt + * @param {string} serverId + * @param {{ now?: number, ttlMs?: number }} [opts] + * @returns {boolean} + */ +export function shouldSendHeartbeat(receipt, serverId, opts = {}) { + if (!serverId) return false; + const now = opts.now ?? Date.now(); + const ttlMs = opts.ttlMs ?? HEARTBEAT_TTL_MS; + const lastPingAt = receipt?.servers?.[serverId]?.lastPingAt; + if (!lastPingAt) return true; + const ageMs = now - new Date(lastPingAt).getTime(); + if (!Number.isFinite(ageMs)) return true; + return ageMs >= ttlMs; +} + +/** + * Record that a heartbeat was attempted for serverId. + * @param {{ schemaVersion: number, servers: Record }} receipt + * @param {string} serverId + * @param {{ now?: number }} [opts] + * @returns {{ schemaVersion: number, servers: Record }} + */ +export function recordHeartbeat(receipt, serverId, opts = {}) { + const now = opts.now ?? Date.now(); + const root = normalizeHeartbeatReceipt(receipt); + root.servers[serverId] = { lastPingAt: new Date(now).toISOString() }; + return root; +} + +/** + * Exclusive per-server lock (best-effort across processes). + * @param {string} serverId + * @param {{ now?: number, lockPath?: string }} [opts] + * @returns {{ unlock: () => void } | null} + */ +export function tryAcquireHeartbeatLock(serverId, opts = {}) { + const now = opts.now ?? Date.now(); + const lockPath = opts.lockPath ?? heartbeatLockPath(serverId); + mkdirSync(path.dirname(lockPath), { recursive: true }); + try { + const fd = openSync(lockPath, "wx"); + writeFileSync(fd, `${now}\n`); + return { + unlock() { + try { + closeSync(fd); + } catch { + /* ignore */ + } + try { + unlinkSync(lockPath); + } catch { + /* ignore */ + } + }, + }; + } catch (err) { + if (err?.code !== "EEXIST") { + log.debug("heartbeat lock open failed", { + error: err?.message ?? String(err), + }); + return null; + } + // Stale lock from a crashed process — reclaim. + try { + const age = now - Number(readFileSync(lockPath, "utf8").trim()); + if (Number.isFinite(age) && age >= LOCK_STALE_MS) { + unlinkSync(lockPath); + return tryAcquireHeartbeatLock(serverId, opts); + } + } catch { + /* ignore */ + } + return null; + } +} + +/** + * Detached `jf rt ping --server-id ` with hook User-Agent. + * Waits for spawn success vs async error before unref. + * @param {string} serverId + * @param {{ spawn?: typeof spawn, env?: NodeJS.ProcessEnv }} [opts] + * @returns {Promise} true if the process started + */ +export function spawnHeartbeatPing(serverId, opts = {}) { + const spawnImpl = opts.spawn ?? spawn; + const env = opts.env ?? process.env; + return new Promise((resolve) => { + let settled = false; + const finish = (ok) => { + if (settled) return; + settled = true; + resolve(ok); + }; + let child; + try { + child = spawnImpl("jf", ["rt", "ping", "--server-id", serverId], { + detached: true, + stdio: "ignore", + env: envWithHookUserAgent(env), + }); + } catch (err) { + log.warn("heartbeat ping spawn threw", { + serverId, + error: err?.message ?? String(err), + }); + finish(false); + return; + } + child.once?.("error", (err) => { + log.warn("heartbeat ping spawn error", { + serverId, + error: err?.message ?? String(err), + }); + finish(false); + }); + child.once?.("spawn", () => { + child.unref?.(); + finish(true); + }); + // Some doubles only expose EventEmitter without 'spawn'; settle soon. + setImmediate(() => { + if (!settled) { + child.unref?.(); + finish(true); + } + }); + }); +} + +/** + * Best-effort daily heartbeat. Never throws. + * @param {{ + * getIdentity?: () => { serverId?: string | null } | null, + * readReceipt?: () => ReturnType, + * writeReceipt?: (r: ReturnType) => void, + * spawnPing?: (serverId: string) => boolean | Promise, + * acquireLock?: (serverId: string) => { unlock: () => void } | null, + * now?: number, + * ttlMs?: number, + * }} [deps] + * @returns {Promise<{ sent: boolean, reason: string, serverId?: string }> | { sent: boolean, reason: string, serverId?: string }} + */ +export function maybeSendAprHeartbeat(deps = {}) { + try { + const getIdentity = + deps.getIdentity ?? (() => getPlatformIdentity().identity); + const identity = getIdentity(); + if (!identity) { + log.debug("heartbeat skip: no identity"); + return { sent: false, reason: "no-identity" }; + } + const serverId = + typeof identity.serverId === "string" && identity.serverId + ? identity.serverId + : null; + if (!serverId) { + log.debug("heartbeat skip: no serverId"); + return { sent: false, reason: "no-server-id" }; + } + + const readReceipt = deps.readReceipt ?? readHeartbeatReceipt; + const writeReceipt = deps.writeReceipt ?? writeHeartbeatReceipt; + const spawnPing = deps.spawnPing ?? ((id) => spawnHeartbeatPing(id)); + const acquireLock = + deps.acquireLock ?? + ((id) => tryAcquireHeartbeatLock(id, { now: deps.now })); + const now = deps.now ?? Date.now(); + const ttlMs = deps.ttlMs ?? HEARTBEAT_TTL_MS; + + const receipt = readReceipt(); + if (!shouldSendHeartbeat(receipt, serverId, { now, ttlMs })) { + log.debug("heartbeat skip: fresh receipt", { serverId }); + return { sent: false, reason: "fresh", serverId }; + } + + const lock = acquireLock(serverId); + if (!lock) { + log.debug("heartbeat skip: lock held", { serverId }); + return { sent: false, reason: "locked", serverId }; + } + + /** @type {ReturnType} */ + let priorReceipt; + try { + // Re-check under lock — another process may have claimed. + priorReceipt = readReceipt(); + if (!shouldSendHeartbeat(priorReceipt, serverId, { now, ttlMs })) { + log.debug("heartbeat skip: fresh under lock", { serverId }); + return { sent: false, reason: "fresh", serverId }; + } + writeReceipt(recordHeartbeat(priorReceipt, serverId, { now })); + } finally { + // Lock covers the claim only; spawn runs unlocked. + lock.unlock(); + } + + const rollback = () => { + try { + writeReceipt(priorReceipt); + } catch (err) { + log.warn("heartbeat receipt rollback failed", { + serverId, + error: err?.message ?? String(err), + }); + } + }; + + try { + const started = spawnPing(serverId); + const finish = (ok) => { + if (!ok) { + rollback(); + return { sent: false, reason: "spawn-failed", serverId }; + } + log.debug("heartbeat ping spawned", { serverId }); + return { sent: true, reason: "spawned", serverId }; + }; + + if (started && typeof started.then === "function") { + return started.then(finish).catch((err) => { + log.warn("heartbeat ping spawn failed", { + serverId, + error: err?.message ?? String(err), + }); + rollback(); + return { sent: false, reason: "spawn-failed", serverId }; + }); + } + return finish(Boolean(started)); + } catch (err) { + log.warn("heartbeat ping spawn failed", { + serverId, + error: err?.message ?? String(err), + }); + rollback(); + return { sent: false, reason: "spawn-failed", serverId }; + } + } catch (err) { + log.warn("maybeSendAprHeartbeat failed", { + error: err?.message ?? String(err), + }); + return { sent: false, reason: "error" }; + } +} diff --git a/plugin/modules/package-resolution/scripts/configure.mjs b/plugin/modules/package-resolution/scripts/configure.mjs new file mode 100644 index 0000000..9979cee --- /dev/null +++ b/plugin/modules/package-resolution/scripts/configure.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +// Agent Package Resolution configure CLI — status + Consent Enable. +// +// Invoked by the agent (absolute path baked into the session-injected +// onboarding nudge / onboarding-procedure). Mutates ~/.jfrog/agents-conf.json. +// Per-type No writes ~/.jfrog/skills-cache/apr-onboarding-v1.json (declining +// pypi does not silence a later npm offer); bare dismiss sets +// onboardingPrompt: "off" (global silence, every type). +// Bounded repo lookup is via the base jfrog skill; this CLI verifies + writes. + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { + ensureAgentsConfigScaffold, + getOnboardingPromptState, + loadAgentsConfig, + mergeAgentsConfigPatch, + normalizeAutoSetup, + normalizeRepoMap, +} from "../../core/agents-config.mjs"; +import { createLogger, setLogContext } from "../../core/logger.mjs"; +import { isNeverConfiguredScaffold } from "../../core/scaffold-fingerprint.mjs"; +import { PACKAGE_TYPES } from "./repo-types.mjs"; +import { isPackageResolutionEnabled } from "./feature-flag.mjs"; +import { verifyRepoKey } from "./verify-repo.mjs"; +import { listDeclinedOnboardingTypes } from "./onboarding-decline-cache.mjs"; +import { + dismissOnboardingPrompt, + dismissOnboardingType, + evaluateOnboardingEligibility, + evaluateOnboardingOfferWindow, + listOfferablePackageTypes, +} from "./onboarding.mjs"; + +const log = createLogger("configure"); +const here = path.dirname(fileURLToPath(import.meta.url)); + +function usage() { + return `Usage: node configure.mjs [options] + +Commands: + status Print APR + onboarding status (JSON) + verify-repo --type --repo Verify one virtual repo key + enable --repos Write enabled:true + defaultGlobalRepos + auto-setup --types Set autoSetup policy + dismiss [--type ] Per-type decline, or global onboardingPrompt off + onboarding-procedure Print stage-2 Consent Enable instructions +`; +} + +function fail(message, code = 1) { + process.stderr.write(`${message}\n`); + process.exit(code); +} + +function parseArgs(argv) { + const args = argv.slice(2); + const command = args[0]; + const opts = {}; + for (let i = 1; i < args.length; i++) { + const a = args[i]; + if ( + a === "--repos" || + a === "--types" || + a === "--type" || + a === "--repo" + ) { + const v = args[++i]; + if (v === undefined) fail(`missing value for ${a}`); + opts[a.slice(2)] = v; + } else if (a === "--help" || a === "-h") { + opts.help = true; + } else { + fail(`unknown argument: ${a}`); + } + } + return { command, opts }; +} + +function parseJsonArg(raw, label) { + try { + return JSON.parse(raw); + } catch { + fail(`${label} must be valid JSON`); + } +} + +function validateRepos(repos) { + const map = normalizeRepoMap(repos); + const keys = Object.keys(map); + if (!keys.length) fail("--repos must include at least one type → repoKey"); + const allowed = new Set(PACKAGE_TYPES); + for (const t of keys) { + if (!allowed.has(t)) fail(`unsupported package type: ${t}`); + } + return map; +} + +async function cmdStatus() { + ensureAgentsConfigScaffold(); + const flag = await isPackageResolutionEnabled(); + const cfg = loadAgentsConfig(); + const prompt = getOnboardingPromptState(); + const elig = evaluateOnboardingEligibility(); + const window = evaluateOnboardingOfferWindow(); + const declined = listDeclinedOnboardingTypes(); + const offerable = listOfferablePackageTypes(); + const out = { + mode: flag.mode, + reason: flag.reason, + cause: flag.cause, + enabled: cfg.packageResolution.enabled, + onboardingPrompt: prompt, + scaffoldUntouched: isNeverConfiguredScaffold(), + eligible: elig.eligible, + eligibilityReason: elig.reason, + offerWindowOpen: window.eligible, + offerWindowReason: window.reason, + declined, + offerable, + defaultGlobalRepos: cfg.packageResolution.defaultGlobalRepos, + autoSetup: cfg.packageResolution.autoSetup, + }; + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); +} + +async function cmdVerifyRepo(opts) { + if (!opts.type || !opts.repo) { + fail("verify-repo requires --type --repo "); + } + const result = await verifyRepoKey({ type: opts.type, repoKey: opts.repo }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.ok) process.exit(1); +} + +async function cmdEnable(opts) { + if (!opts.repos) fail("enable requires --repos ''"); + const repos = validateRepos(parseJsonArg(opts.repos, "--repos")); + ensureAgentsConfigScaffold(); + + /** @type {Record} */ + const verified = {}; + for (const [type, repoKey] of Object.entries(repos)) { + const result = await verifyRepoKey({ type, repoKey }); + if (!result.ok) { + fail( + `enable refused unverified repo ${type}=${repoKey}: ${result.cause ?? "verify-failed"}`, + ); + } + verified[type] = repoKey; + } + + mergeAgentsConfigPatch({ + packageResolution: { + enabled: true, + defaultGlobalRepos: verified, + }, + }); + const elig = evaluateOnboardingEligibility(); + process.stdout.write( + `${JSON.stringify({ + ok: true, + enabled: true, + defaultGlobalRepos: verified, + offer: elig.eligible, + offerable: listOfferablePackageTypes(), + next: [ + `node "${path.join(here, "configure.mjs")}" auto-setup --types ''`, + `JFROG_EAGER_SETUP_SYNC=1 node "${path.join(here, "print-policy.mjs")}"`, + "After auto-setup + sync print-policy: suggest a new chat so hooks reload cleanly", + ], + })}\n`, + ); +} + +function cmdAutoSetup(opts) { + if (opts.types === undefined) + fail("auto-setup requires --types ''"); + ensureAgentsConfigScaffold(); + const cfg = loadAgentsConfig(); + if (cfg.packageResolution.enabled !== true) { + fail("auto-setup requires packageResolution.enabled: true"); + } + const bound = Object.keys(cfg.packageResolution.defaultGlobalRepos ?? {}); + if (!bound.length) { + fail( + "auto-setup requires at least one type in packageResolution.defaultGlobalRepos", + ); + } + const boundSet = new Set(bound); + /** @type {true | string[]} */ + let autoSetup; + if (opts.types === "true" || opts.types === true) { + // Expand to currently bound types only — never schedule unbound types. + autoSetup = [...bound].sort(); + } else { + const raw = parseJsonArg(opts.types, "--types"); + autoSetup = normalizeAutoSetup(raw); + if (autoSetup === true) { + autoSetup = [...bound].sort(); + } else if (!autoSetup.length) { + fail("--types must be true or a non-empty JSON array of package types"); + } else { + const allowed = new Set(PACKAGE_TYPES); + for (const t of autoSetup) { + if (!allowed.has(t)) fail(`unsupported package type: ${t}`); + if (!boundSet.has(t)) { + fail( + `auto-setup type not in defaultGlobalRepos: ${t} (bound: ${bound.sort().join(", ")})`, + ); + } + } + } + } + mergeAgentsConfigPatch({ packageResolution: { autoSetup } }); + process.stdout.write(`${JSON.stringify({ ok: true, autoSetup })}\n`); +} + +function cmdDismiss(opts) { + if (opts.type !== undefined) { + const allowed = new Set(PACKAGE_TYPES); + if (!allowed.has(opts.type)) { + fail(`unsupported package type: ${opts.type}`); + } + const out = dismissOnboardingType(opts.type); + process.stdout.write(`${JSON.stringify(out)}\n`); + return; + } + dismissOnboardingPrompt(); + process.stdout.write( + `${JSON.stringify({ ok: true, onboardingPrompt: "off" })}\n`, + ); +} + +function cmdOnboardingProcedure() { + const templatePath = path.join( + here, + "../onboarding/package-resolution-onboarding-procedure.md", + ); + let body; + try { + body = readFileSync(templatePath, "utf8"); + } catch (err) { + fail(`onboarding-procedure template unreadable: ${err?.message ?? err}`); + } + const configurePath = path.join(here, "configure.mjs"); + const printPath = path.join(here, "print-policy.mjs"); + body = body.replace(/\{\{CONFIGURE_COMMAND\}\}/g, configurePath); + body = body.replace(/\{\{PRINT_POLICY_COMMAND\}\}/g, printPath); + process.stdout.write(body.endsWith("\n") ? body : `${body}\n`); +} + +async function main() { + setLogContext({ ide: "configure" }); + const { command, opts } = parseArgs(process.argv); + if (!command || opts.help) { + process.stdout.write(usage()); + process.exit(command ? 0 : 1); + } + switch (command) { + case "status": + await cmdStatus(); + break; + case "verify-repo": + await cmdVerifyRepo(opts); + break; + case "enable": + await cmdEnable(opts); + break; + case "auto-setup": + cmdAutoSetup(opts); + break; + case "dismiss": + cmdDismiss(opts); + break; + case "onboarding-procedure": + cmdOnboardingProcedure(); + break; + default: + fail(`unknown command: ${command}\n${usage()}`); + } +} + +main().catch((err) => { + log.warn("configure failed", { error: err?.message ?? String(err) }); + fail(err?.message ?? String(err)); +}); diff --git a/plugin/modules/package-resolution/scripts/eager-setup.mjs b/plugin/modules/package-resolution/scripts/eager-setup.mjs index c1fe710..40874c1 100644 --- a/plugin/modules/package-resolution/scripts/eager-setup.mjs +++ b/plugin/modules/package-resolution/scripts/eager-setup.mjs @@ -6,6 +6,9 @@ // need `jf setup` (per the receipt), spawn a DETACHED background worker for // them, and return a short status note for the injected instruction. Never // runs `jf setup` itself — injection must stay fast (< 7s hook budget). +// Exception: `JFROG_EAGER_SETUP_SYNC=1` waits for the worker, then +// re-reads the receipt so the note says `already set up` instead of +// `setting up in the background` (Consent Enable print-policy). // 2. WORKER (background, `node eager-setup.mjs --run `): take a // global lock, re-check the receipt, run `jf setup --server-id --repo` // one package manager at a time with a per-package-manager timeout, and @@ -32,7 +35,11 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { createLogger } from "../../core/logger.mjs"; -import { loadAgentsConfig, isAutoSetup } from "../../core/agents-config.mjs"; +import { + loadAgentsConfig, + isAutoSetup, + globalDeclaredTypes, +} from "../../core/agents-config.mjs"; import { getPlatformIdentity } from "../../core/jf-identity.mjs"; import { prepareSessionResolve, @@ -42,6 +49,7 @@ import { import { readReceipt, writeReceipt, + receiptEntry, evaluateSetupNeed, applySetupResult, } from "./eager-setup-receipt.mjs"; @@ -51,6 +59,7 @@ import { packageManagerBinaryOnPath, } from "./package-manager-family.mjs"; import { detectSetupConflict } from "./setup-conflict.mjs"; +import { envWithHookUserAgent } from "../../core/jf-user-agent.mjs"; const log = createLogger("eager-setup"); @@ -60,13 +69,12 @@ const MAX_PACKAGE_MANAGER_JOBS = Object.values(TYPE_TO_PACKAGE_MANAGERS).reduce( 0, ); -/** Actionable hint when autoSetup names a type that isn't governed. */ +/** Actionable hint when autoSetup names a type that isn't admin-declared. */ function ungovernedAutoSetupHint(type) { return ( `trying to eager-configure '${type}' via autoSetup but it is not ` + - "governed — no repo found in defaultGlobalRepos " + - "(~/.jfrog/agents-conf.json) or repositories in " + - ".jfrog/local/package-resolution.json" + "admin-declared in defaultGlobalRepos (~/.jfrog/agents-conf.json). " + + "Workspace-only types are never autoSetup-eligible." ); } @@ -93,8 +101,9 @@ function workerPath() { // --------------------------------------------------------------------------- /** - * Compute eligible eager-setup jobs = governed ∩ resolved ∩ autoSetup, - * expanded to one job per package manager in that type's family (Option C). + * Compute eligible eager-setup jobs = admin-declared ∩ resolved ∩ autoSetup + * (workspace-only types never eager-setup), expanded to one job per package + * manager in that type's family (Option C). * Warns when `autoSetup` names an ungoverned type (ignored, not fatal). * Binary presence and `jf setup --help` are checked later (orchestrator/worker). * @param {string[]} governed @@ -102,9 +111,15 @@ function workerPath() { * @returns {{type:string, repoKey:string, packageManager:string}[]} */ export function computeEligibleJobs(governed, resolvedByType) { - const governedSet = new Set(governed); + const adminSet = new Set(globalDeclaredTypes()); const jobs = []; for (const type of governed) { + if (!adminSet.has(type)) { + log.debug("eager skip: workspace-only type is not autoSetup-eligible", { + type, + }); + continue; + } if (!isAutoSetup(type)) continue; const r = resolvedByType[type]; if (!r) { @@ -120,11 +135,12 @@ export function computeEligibleJobs(governed, resolvedByType) { jobs.push({ type, repoKey: r.repoKey, packageManager }); } } - // Surface admin misconfig: autoSetup naming a type that isn't governed. + // Surface admin misconfig: autoSetup naming a type that isn't admin-declared + // (`autoSetup: true` skips workspace-only types without warning). const { autoSetup } = loadAgentsConfig().packageResolution; if (Array.isArray(autoSetup)) { for (const type of autoSetup) { - if (!governedSet.has(type)) { + if (!adminSet.has(type)) { log.warn(`eager setup skipped: ${ungovernedAutoSetupHint(type)}`, { type, }); @@ -384,6 +400,29 @@ export async function orchestrateEagerSetup(ctx = {}) { "utf8", ).toString("base64"); spawnWorker(payload, toRun.length); + if (process.env.JFROG_EAGER_SETUP_SYNC === "1") { + // spawnSync already waited. Re-bucket from the receipt so Consent + // Enable print-policy does not still say "setting up in the + // background" (that line is the agent's cue to rewrite with + // --registry / --index-url / GOPROXY). Use the receipt entry, not + // evaluateSetupNeed: ttl=0 would still look "needed" after a + // successful setup. + const after = await readReceipt(); + pending.length = 0; + for (const job of toRun) { + const entry = receiptEntry(after, serverId, job.packageManager); + if (entry?.status === "ok" && entry.repoKey === job.repoKey) { + configured.push(job.packageManager); + } else if ( + entry?.status === "failed" && + entry.repoKey === job.repoKey + ) { + deferred.push(job.packageManager); + } else { + pending.push(job.packageManager); + } + } + } } } @@ -603,9 +642,11 @@ export function releaseLock() { */ function supportedPackageManagers() { try { + // --help is local (no Artifactory traffic); no UA needed for telemetry. const res = spawnSync("jf", ["setup", "--help"], { encoding: "utf8", timeout: 5000, + env: process.env, }); const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`; const m = out.match(/Supported package managers are:\s*([^.\n]+)/i); @@ -663,6 +704,7 @@ function runJfSetup(packageManager, serverId, repoKey) { const res = spawnSync("jf", args, { encoding: "utf8", timeout: PER_PACKAGE_MANAGER_TIMEOUT_MS, + env: envWithHookUserAgent(process.env), }); if (res.error) { return { ok: false, reason: `spawn error: ${res.error.message}` }; diff --git a/plugin/modules/package-resolution/scripts/feature-flag.mjs b/plugin/modules/package-resolution/scripts/feature-flag.mjs index 23cf036..aa48a3d 100644 --- a/plugin/modules/package-resolution/scripts/feature-flag.mjs +++ b/plugin/modules/package-resolution/scripts/feature-flag.mjs @@ -5,12 +5,13 @@ // // 1. JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1 → mode="off" (env kill switch) // 2. packageResolution.enabled !== true in → mode="off" (file-primary gate; -// ~/.jfrog/agents-conf.json default off in shipped template) +// ~/.jfrog/agents-conf.json shipped template defaults on) // 3. jf config + readiness probe (via jf-identity) // → mode="routing" when identity is usable and Artifactory accepts it; // otherwise mode="pending" with a `cause`: // jf-not-installed | jf-not-configured | jf-unsupported-auth | -// jf-auth-failed | jf-unreachable +// jf-auth-failed | insecure-url +// (jf-unreachable stays routing best-effort — not a pending cause) // // Modes: // "off" — do nothing (no injection). diff --git a/plugin/modules/package-resolution/scripts/index.mjs b/plugin/modules/package-resolution/scripts/index.mjs index 8551012..1b2a5f3 100644 --- a/plugin/modules/package-resolution/scripts/index.mjs +++ b/plugin/modules/package-resolution/scripts/index.mjs @@ -3,9 +3,30 @@ // Invoked by modules/*-session-start.mjs via run-capability.mjs (argv capability name). // Performs NO harness-specific I/O (no stdin/stdout). +import { createLogger } from "../../core/logger.mjs"; import { isPackageResolutionEnabled } from "./feature-flag.mjs"; import { renderInstruction } from "./render-instruction.mjs"; import { orchestrateEagerSetup } from "./eager-setup.mjs"; +import { maybeSendAprHeartbeat } from "./apr-heartbeat.mjs"; +import { + maybeMigrateScaffoldEnabled, + resolveOnboardingNudge, +} from "./onboarding.mjs"; + +const log = createLogger("package-resolution"); + +/** + * Adapter `ctx.ide` → UA wire `tool=` token. + * Only hooks-specific mapping (`claude_code` → `claude`). Env-marker harness + * detection stays in CLI (`ai-agent/`); model stamps only in skills when known. + * @param {string | undefined} ide + * @returns {string | undefined} + */ +function wireToolFromIde(ide) { + if (ide === "claude_code") return "claude"; + if (ide === "cursor" || ide === "copilot") return ide; + return undefined; +} export const packageResolution = { name: "package-resolution", @@ -17,9 +38,50 @@ export const packageResolution = { /** @returns {Promise} markdown instruction text, or "" when no-op */ async sessionStart(ctx = {}) { + // Kill switch must not persist enabled:true on a legacy scaffold — that + // would activate APR the moment DISABLE is later removed, without consent. + if (process.env.JF_AGENT_PACKAGE_RESOLUTION_DISABLE !== "1") { + try { + maybeMigrateScaffoldEnabled(); + } catch (err) { + log.warn("scaffold enabled migration failed", { + error: err?.message ?? String(err), + }); + } + } + const flag = await isPackageResolutionEnabled(); this.mode = flag.mode; + // Hook UA tool= from adapter id; CLI may still append ai-agent/ from env. + const tool = wireToolFromIde(ctx.ide); + if (tool) process.env.JFROG_APR_UA_TOOL = tool; + + const killSwitch = flag.mode === "off" && flag.reason === "DISABLE"; + let nudge = { offer: false, reason: "nudge-error", text: "" }; + try { + nudge = resolveOnboardingNudge({ ide: ctx.ide, killSwitch }); + } catch (err) { + log.warn("onboarding nudge failed", { + error: err?.message ?? String(err), + }); + } + + // Off: only the nudge (if eligible) is injected, no routing/pending policy. + if (flag.mode === "off") { + this.meta = { + reason: flag.reason, + identity: flag.identity ?? "-", + nudge: nudge.offer, + nudgeReason: nudge.reason, + mode: "off", + }; + return nudge.text; + } + + // Enabled paths: inject pending/routing; nudge still injected alongside + // it when types remain unbound + undeclined. + // Feature 2 — auto setup on startup. Only in routing mode (identity + // resolution available). Runs OFF the critical path: it just decides what // needs setup, spawns a detached worker, and returns a note. Never @@ -27,19 +89,27 @@ export const packageResolution = { let autoSetupStatus = ""; if (flag.mode === "routing") { autoSetupStatus = await orchestrateEagerSetup(ctx); + // Daily best-effort `jf rt ping` (trigger=hook UA) so observability still + // sees APR sessions when eager setup is skipped. Never throws. + await Promise.resolve(maybeSendAprHeartbeat()); } const { text, meta } = await renderInstruction(flag, { ...ctx, autoSetupStatus, }); + const combined = [text, nudge.text] + .filter((t) => t?.trim()) + .join("\n\n---\n\n"); this.meta = { reason: flag.reason, identity: flag.identity ?? "-", + nudge: nudge.offer, + nudgeReason: nudge.reason, ...(autoSetupStatus ? { eagerSetup: true } : {}), ...meta, }; - return text; + return combined; }, }; diff --git a/plugin/modules/package-resolution/scripts/onboarding-decline-cache.mjs b/plugin/modules/package-resolution/scripts/onboarding-decline-cache.mjs new file mode 100644 index 0000000..5d97992 --- /dev/null +++ b/plugin/modules/package-resolution/scripts/onboarding-decline-cache.mjs @@ -0,0 +1,225 @@ +// Per-type APR onboarding decline cache. +// +// Durable "No" for one package type lives here — not in agents-conf.json — +// so declining pypi does not silence a later npm offer. +// +// File: ~/.jfrog/skills-cache/apr-onboarding-v1.json +// { +// "schema": 1, +// "declined": { +// "pypi": { "at": "2026-08-17T10:00:00.000Z" } +// } +// } + +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { createLogger } from "../../core/logger.mjs"; +import { PACKAGE_TYPES } from "./repo-types.mjs"; + +const log = createLogger("onboarding-decline-cache"); + +const SCHEMA = 1; +const ALLOWED = new Set(PACKAGE_TYPES); +const DECLINE_CACHE_LOCK_STALE_MS = 30_000; +const DECLINE_CACHE_LOCK_WAIT_MS = 1_000; +const DECLINE_CACHE_LOCK_POLL_MS = 25; + +/** @returns {string} `~/.jfrog/skills-cache` */ +function cacheDir(home = homedir()) { + return path.join(home, ".jfrog", "skills-cache"); +} + +/** @param {string} [home] */ +export function onboardingDeclineCachePath(home = homedir()) { + return path.join(cacheDir(home), "apr-onboarding-v1.json"); +} + +/** @param {string} [home] */ +function declineCacheLockPath(home = homedir()) { + return path.join(cacheDir(home), "apr-onboarding-v1.lock"); +} + +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function tryDeclineCacheLock(home) { + mkdirSync(path.dirname(declineCacheLockPath(home)), { recursive: true }); + const fd = openSync(declineCacheLockPath(home), "wx"); + try { + writeFileSync(fd, `${process.pid}\n${Date.now()}\n`); + } finally { + closeSync(fd); + } +} + +function releaseDeclineCacheLock(home) { + try { + unlinkSync(declineCacheLockPath(home)); + } catch { + // ignore + } +} + +function reclaimStaleDeclineCacheLock(home, nowMs) { + const lock = declineCacheLockPath(home); + 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); + // Incomplete wx→write lock files have no timestamp yet. Never treat those + // as stale or a concurrent waiter steals the lock and last-write wins. + const ageMs = hasStamp ? nowMs - ts : nowMs - statSync(lock).mtimeMs; + if (ageMs > DECLINE_CACHE_LOCK_STALE_MS) { + unlinkSync(lock); + return true; + } + } catch { + // ignore + } + return false; +} + +function acquireDeclineCacheLock(home, nowMs = Date.now()) { + try { + tryDeclineCacheLock(home); + return true; + } catch { + if (!reclaimStaleDeclineCacheLock(home, nowMs)) return false; + try { + tryDeclineCacheLock(home); + return true; + } catch { + return false; + } + } +} + +/** + * Serialize read-modify-write of the decline cache across processes. + * @template T + * @param {string} home + * @param {() => T} fn + * @returns {T} + */ +function withDeclineCacheLock(home, fn) { + const deadline = Date.now() + DECLINE_CACHE_LOCK_WAIT_MS; + let locked = acquireDeclineCacheLock(home); + while (!locked && Date.now() < deadline) { + sleepSync(DECLINE_CACHE_LOCK_POLL_MS); + locked = acquireDeclineCacheLock(home, Date.now()); + } + if (!locked) { + throw new Error( + "apr-onboarding-v1.lock: could not acquire lock within wait budget", + ); + } + try { + return fn(); + } finally { + releaseDeclineCacheLock(home); + } +} + +/** @returns {{ schema: number, declined: Record }} */ +function emptyCache() { + return { schema: SCHEMA, declined: {} }; +} + +/** + * @param {unknown} data + * @returns {{ schema: number, declined: Record }} + */ +export function normalizeOnboardingDeclineCache(data) { + if (!data || typeof data !== "object" || data.schema !== SCHEMA) { + return emptyCache(); + } + /** @type {Record} */ + const declined = {}; + const raw = data.declined; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const [type, entry] of Object.entries(raw)) { + if (!ALLOWED.has(type)) continue; + if (!entry || typeof entry !== "object") continue; + const at = typeof entry.at === "string" && entry.at ? entry.at : null; + if (!at) continue; + declined[type] = { at }; + } + } + return { schema: SCHEMA, declined }; +} + +/** + * @param {string} [home] + * @returns {{ schema: number, declined: Record }} + */ +export function readOnboardingDeclineCache(home = homedir()) { + const file = onboardingDeclineCachePath(home); + try { + if (!existsSync(file)) return emptyCache(); + return normalizeOnboardingDeclineCache( + JSON.parse(readFileSync(file, "utf8")), + ); + } catch (err) { + log.warn("onboarding decline cache unreadable; treating as empty", { + error: err?.message ?? String(err), + }); + return emptyCache(); + } +} + +/** + * @param {string} [home] + * @returns {string[]} + */ +export function listDeclinedOnboardingTypes(home = homedir()) { + return Object.keys(readOnboardingDeclineCache(home).declined).sort(); +} + +/** + * @param {{ schema: number, declined: Record }} root + * @param {string} [home] + */ +function writeCache(root, home = homedir()) { + const file = onboardingDeclineCachePath(home); + mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${JSON.stringify(root, null, 2)}\n`); + renameSync(tmp, file); +} + +/** + * Record a durable per-type decline. + * @param {string} type APR package type + * @param {{ at?: string, home?: string }} [opts] + */ +export function declineOnboardingType(type, opts = {}) { + if (!ALLOWED.has(type)) { + throw new Error(`unsupported package type for dismiss: ${type}`); + } + const home = opts.home ?? homedir(); + const at = opts.at ?? new Date().toISOString(); + withDeclineCacheLock(home, () => { + // Re-read under the lock so concurrent declines accumulate. + const root = readOnboardingDeclineCache(home); + root.declined[type] = { at }; + writeCache(root, home); + }); + log.info("onboarding.decline.recorded", { type, at }); +} diff --git a/plugin/modules/package-resolution/scripts/onboarding.mjs b/plugin/modules/package-resolution/scripts/onboarding.mjs new file mode 100644 index 0000000..8c5ce6a --- /dev/null +++ b/plugin/modules/package-resolution/scripts/onboarding.mjs @@ -0,0 +1,248 @@ +// APR onboarding offer eligibility + session-injected nudge rendering. +// +// The nudge used to be delivered as a standing rule file on disk. It is now +// rendered here and returned as plain text; the caller (index.mjs) hands it +// to the SessionStart hook's own additionalContext channel. +// +// Per-type: declining one package type does not silence the offer for the +// others — a durable per-type decline lives in onboarding-decline-cache.mjs, +// not in agents-conf.json. The nudge's type list is the still-offerable set +// (unbound AND undeclined), so it only ever shrinks as types get bound or +// declined — it never grows the amount of text injected at SessionStart. +// `dismiss` with no type is a global escape hatch (silences everything via +// onboardingPrompt: "off"); `dismiss --type ` is the normal per-type "no". + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + getOnboardingPromptState, + loadAgentsConfig, + mergeAgentsConfigPatch, +} from "../../core/agents-config.mjs"; +import { isNeverConfiguredScaffold } from "../../core/scaffold-fingerprint.mjs"; +import { createLogger } from "../../core/logger.mjs"; +import { + declineOnboardingType, + listDeclinedOnboardingTypes, +} from "./onboarding-decline-cache.mjs"; +import { PACKAGE_TYPES } from "./repo-types.mjs"; + +const log = createLogger("onboarding"); +const here = path.dirname(fileURLToPath(import.meta.url)); +const NUDGE_TEMPLATE = path.join(here, "../onboarding/session-start-nudge.md"); + +export const CURSOR_ADMIN_GUIDE_URL = + "https://github.com/jfrog/cursor-plugin/blob/main/docs/package-resolution-admin-guide.md"; +export const CLAUDE_ADMIN_GUIDE_URL = + "https://github.com/jfrog/claude-plugin/blob/main/docs/package-resolution-admin-guide.md"; +export const COPILOT_ADMIN_GUIDE_URL = + "https://github.com/jfrog/vscode-plugin/blob/main/docs/package-resolution-admin-guide.md"; + +const ADMIN_GUIDE_URL_BY_IDE = { + claude_code: CLAUDE_ADMIN_GUIDE_URL, + cursor: CURSOR_ADMIN_GUIDE_URL, + copilot: COPILOT_ADMIN_GUIDE_URL, +}; + +/** Human-readable list of APR package types (keeps nudge copy in sync with code). */ +export function supportedTypesPhrase() { + return PACKAGE_TYPES.join(", "); +} + +function adminGuideUrlForIde(ide) { + return ADMIN_GUIDE_URL_BY_IDE[ide] ?? CLAUDE_ADMIN_GUIDE_URL; +} + +function configureCommandPath() { + return path.join(here, "configure.mjs"); +} + +/** + * Render the short session-injected onboarding nudge. The template's first + * sentence is the "wait for real install intent" instruction — SessionStart + * only fires at startup/resume/clear/compact, so that timing gate has to + * live in the text itself, not in code. + * + * `types` should be the still-offerable set (unbound AND undeclined) so the + * rendered list only ever shrinks as types get bound/declined — it never + * grows the amount of text injected at SessionStart. Defaults to every + * supported type for callers that don't have an eligibility result handy. + * @param {{ ide?: string, types?: string[] }} [opts] + * @returns {string} empty when the template is unreadable + */ +export function renderOnboardingNudge(opts = {}) { + try { + let body = readFileSync(NUDGE_TEMPLATE, "utf8"); + const types = opts.types ?? PACKAGE_TYPES; + body = body.replace(/\{\{SUPPORTED_TYPES\}\}/g, types.join(", ")); + body = body.replace( + /\{\{ADMIN_GUIDE_URL\}\}/g, + adminGuideUrlForIde(opts.ide), + ); + body = body.replace( + /\{\{CONFIGURE_COMMAND\}\}/g, + configureCommandPath().replace(/\\/g, "\\\\"), + ); + return body.trim(); + } catch (err) { + log.warn("onboarding nudge template unreadable", { + error: err?.message ?? String(err), + }); + return ""; + } +} + +/** + * Flip never-configured scaffolds to enabled:true (and onboardingPrompt:auto + * when the field was absent so the offer gate survives the fingerprint change). + * @returns {{ migrated: boolean }} + */ +export function maybeMigrateScaffoldEnabled() { + if (!isNeverConfiguredScaffold()) return { migrated: false }; + if (getOnboardingPromptState() === "off") return { migrated: false }; + const cfg = loadAgentsConfig(); + if (cfg.packageResolution.enabled === true) return { migrated: false }; + + /** @type {Record} */ + const patch = { enabled: true }; + if (getOnboardingPromptState() === "absent") { + patch.onboardingPrompt = "auto"; + } + mergeAgentsConfigPatch({ packageResolution: patch }); + log.info("onboarding.scaffold.enabled_migrated", { + setOnboardingPromptAuto: patch.onboardingPrompt === "auto", + }); + return { migrated: true }; +} + +/** + * Global offer gate (ignores per-type declines / bindings). + * @returns {{ open: boolean, reason: string }} + */ +export function evaluateOnboardingGate() { + const prompt = getOnboardingPromptState(); + if (prompt === "off") { + return { open: false, reason: "prompt-off" }; + } + if (prompt === "auto") { + return { open: true, reason: "prompt-auto" }; + } + if (isNeverConfiguredScaffold()) { + return { open: true, reason: "fingerprint-match" }; + } + return { open: false, reason: "fingerprint-miss" }; +} + +/** + * @param {string} [home] + * @returns {Record} + */ +function defaultGlobalReposFor(home = homedir()) { + if (home === homedir()) { + return loadAgentsConfig().packageResolution.defaultGlobalRepos ?? {}; + } + try { + const conf = path.join(home, ".jfrog", "agents-conf.json"); + if (!existsSync(conf)) return {}; + const raw = JSON.parse(readFileSync(conf, "utf8")); + const repos = raw?.packageResolution?.defaultGlobalRepos; + return repos && typeof repos === "object" && !Array.isArray(repos) + ? repos + : {}; + } catch { + return {}; + } +} + +/** + * Types that may still receive a Consent Enable offer — unbound AND + * undeclined. This is the list rendered into the nudge, so it only ever + * shrinks as types get bound (via enable) or declined (via dismiss --type). + * @param {string} [home] + * @returns {string[]} + */ +export function listOfferablePackageTypes(home = homedir()) { + const repos = defaultGlobalReposFor(home); + const declined = new Set(listDeclinedOnboardingTypes(home)); + return PACKAGE_TYPES.filter((type) => { + const key = repos[type]; + const bound = typeof key === "string" && key.trim().length > 0; + return !bound && !declined.has(type); + }); +} + +/** + * Whether the onboarding nudge may currently be shown. + * @returns {{ eligible: boolean, reason: string, offerable?: string[] }} + */ +export function evaluateOnboardingEligibility() { + const gate = evaluateOnboardingGate(); + if (!gate.open) { + return { eligible: false, reason: gate.reason }; + } + const offerable = listOfferablePackageTypes(); + if (!offerable.length) { + return { eligible: false, reason: "nothing-to-offer" }; + } + return { eligible: true, reason: gate.reason, offerable }; +} + +/** Alias kept for callers that check the offer window specifically. */ +export function evaluateOnboardingOfferWindow() { + return evaluateOnboardingEligibility(); +} + +/** + * Resolve whether/what to inject for this SessionStart. Code-level gate only + * — this is layer 1 of the two-layer design in the plan header. Layer 2 (wait + * for real install intent) lives inside the rendered text itself. + * @param {{ ide?: string, killSwitch?: boolean }} [opts] + * @returns {{ offer: boolean, reason: string, offerable?: string[], text: string }} + */ +export function resolveOnboardingNudge(opts = {}) { + if (opts.killSwitch) { + return { offer: false, reason: "DISABLE", text: "" }; + } + const elig = evaluateOnboardingEligibility(); + if (!elig.eligible) { + return { offer: false, reason: elig.reason, text: "" }; + } + const text = renderOnboardingNudge({ ide: opts.ide, types: elig.offerable }); + if (!text) { + return { offer: false, reason: "template-error", text: "" }; + } + return { offer: true, reason: elig.reason, offerable: elig.offerable, text }; +} + +/** Write onboardingPrompt: "off" into agents-conf.json. */ +export function persistOnboardingPromptOff() { + mergeAgentsConfigPatch({ + packageResolution: { onboardingPrompt: "off" }, + }); +} + +/** Global "No" — silence the offer for every type, permanently. */ +export function dismissOnboardingPrompt() { + persistOnboardingPromptOff(); + log.info("onboarding.dismiss.recorded"); +} + +/** + * Per-type "No" — durable decline for one APR package type. Other unbound, + * undeclined types remain offerable. + * @param {string} type + * @returns {{ ok: true, declinedType: string, offerable: string[], offer: boolean }} + */ +export function dismissOnboardingType(type) { + declineOnboardingType(type); + const elig = evaluateOnboardingEligibility(); + return { + ok: true, + declinedType: type, + offerable: listOfferablePackageTypes(), + offer: elig.eligible, + }; +} diff --git a/plugin/modules/package-resolution/scripts/render-instruction.mjs b/plugin/modules/package-resolution/scripts/render-instruction.mjs index 4c81347..012444e 100644 --- a/plugin/modules/package-resolution/scripts/render-instruction.mjs +++ b/plugin/modules/package-resolution/scripts/render-instruction.mjs @@ -53,6 +53,9 @@ function causeIntro(cause) { if (cause === IdentityCause.JF_AUTH_FAILED) { return "`jf` credentials were rejected by Artifactory (expired, revoked, or wrong)"; } + if (cause === IdentityCause.INSECURE_URL) { + return "`jf` is configured with a non-HTTPS platform URL (credentials would be sent in cleartext)"; + } if (cause === IdentityCause.JF_UNREACHABLE) { return "Artifactory did not respond to a readiness probe (network / URL / outage)"; } @@ -83,6 +86,13 @@ function causeRemediation(cause) { "key with `jf config add` / re-login, then retry." ); } + if (cause === IdentityCause.INSECURE_URL) { + return ( + "The JFrog CLI is installed and a server is configured, but the platform " + + "URL is not HTTPS. Reconfigure with `jf config add` using an https:// URL " + + "so credentials are not sent in cleartext." + ); + } if (cause === IdentityCause.JF_UNREACHABLE) { return ( "The JFrog CLI is installed and a server is configured, but Artifactory " + @@ -111,6 +121,9 @@ function causeChecklist(cause) { const refreshCreds = "Refresh credentials (`jf config add` / re-login) and confirm with " + "`jf config show`."; + const reconfigureHttps = + "Reconfigure the server with an https:// platform URL (`jf config add`) " + + "and confirm with `jf config show`."; const checkReachable = "Confirm the platform URL is reachable and Artifactory is healthy, " + "then retry."; @@ -129,6 +142,9 @@ function causeChecklist(cause) { if (cause === IdentityCause.JF_AUTH_FAILED) { return `1. ${refreshCreds}\n2. ${setup}`; } + if (cause === IdentityCause.INSECURE_URL) { + return `1. ${reconfigureHttps}\n2. ${setup}`; + } if (cause === IdentityCause.JF_UNREACHABLE) { return `1. ${checkReachable}\n2. ${setup}`; } @@ -170,10 +186,8 @@ function rewriteBulletFor(type, resolved) { const r = resolved[type]; if (!r) { return ( - `- \`${type}\` — **unresolved** (no Artifactory repo for this package manager yet). ` + - `Per hard rule #5, do not invent a URL: invoke \`jfrog-setup-package-managers\` ` + - `for \`${type}\` BEFORE any direct command. Once the binding is recorded, ` + - `route subsequent \`${type}\` commands through the resolved URL yourself.` + `- \`${type}\` — **unresolved**. Per hard rule #5: invoke \`jfrog-setup-package-managers\` ` + + `for \`${type}\` BEFORE any direct command; then route via the resolved URL.` ); } const url = r.baseUrl; @@ -194,8 +208,11 @@ function rewriteBulletFor(type, resolved) { return `- \`go get \` → \`GOPROXY=${url},direct go get \``; case "docker": return ( - `- \`docker pull [/]acme/app:1.2\` → \`docker pull ${url}/acme/app:1.2\` (drop a leading PUBLIC registry host — \`docker.io\`, \`ghcr.io\`, \`quay.io\`, \`gcr.io\`, …. Leave \`localhost\`/\`127.0.0.1\`, private/internal registries, and the JFrog host itself as-is; if unsure, resolve the host — a private/loopback IP means internal, leave it)\n` + - `- \`podman pull …\` → same prefix rules as docker against \`${url}\`` + `- \`docker pull [/]acme/app:1.2\` → \`docker pull ${url}/acme/app:1.2\` ` + + `(drop leading PUBLIC hosts: \`docker.io\`, \`ghcr.io\`, \`quay.io\`, \`gcr.io\`, …. ` + + `Leave \`localhost\`/\`127.0.0.1\`, private/internal registries, and the JFrog host as-is; ` + + `if unsure, resolve the host — a private/loopback IP means internal, leave it)\n` + + `- \`podman pull …\` → same prefix rules against \`${url}\`` ); case "maven": return `- \`mvn ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; @@ -221,22 +238,31 @@ function buildDockerSection(governed, resolved) { const resolvedDocker = resolved.docker; const body = resolvedDocker ? [ - "- **Bare refs go to Docker Hub.** `docker pull alpine:latest` (no registry host) uses", - " `docker.io` — `jf setup docker` does **not** change that. You must prefix:", - " `docker pull //` using the docker row above (`host/repoKey`, not", - " `https://…`).", - "- **Explicit hosts too.** `docker pull ghcr.io/foo/bar` (or any registry host in the ref)", - " — also route through JFrog: prefix with the docker row above; do not pull from the", - " upstream host directly.", + "- **Bare refs go to Docker Hub.** `docker pull alpine:latest` (no host) uses", + " `docker.io` — `jf setup docker` does **not** change that. Prefix with the docker", + " row above: `docker pull //` (`host/repoKey`, not `https://…`).", + "- **Explicit hosts too.** `docker pull ghcr.io/foo/bar` (or any registry host) — also", + " route through JFrog via the docker row; do not pull the upstream host directly.", ].join("\n") : [ - "- **Unresolved docker ⇒ no docker commands.** The docker row shows", - " ``; do not run `docker pull/run/create` until setup completes", - ' and you have a prefixed ref. Do not "try first, fix later."', + "- **Unresolved docker ⇒ no docker commands.** Do not run `docker pull/run/create`", + " until setup completes and you have a prefixed ref. Do not try first, fix later.", ].join("\n"); return "\n## Docker (before any `docker pull`)\n\n" + body + "\n"; } +// Agent Guard carve-out only when npm is governed (the exception is npm-scoped). +function buildAgentGuardSection(governed) { + if (!governed.includes("npm")) return ""; + return [ + "**Agent Guard bootstrap — exception to Decision step 4 and hard rule #7.** A command that installs", + "`@jfrog/agent-guard` and no other package may keep the registry its Agent Guard policy specifies", + "(e.g. `npx --yes --registry @jfrog/agent-guard`). Nothing else is covered. This exception", + "never authorizes a public registry, and it never widens to another package.", + "", + ].join("\n"); +} + // Pending-mode scope line — the governed package managers are known from config // alone (no network / no resolution needed). Notes that matching package // managers will be @@ -323,7 +349,7 @@ export async function renderInstruction(flag, ctx = {}) { }; } - // routing: resolve only the GOVERNED types (admin defaultGlobalRepos keys) + // routing: resolve governed types (admin ∪ applied workspace overlay) // and build the table / bullets / docker section // dynamically so ungoverned types disappear entirely (not blocked). await prepareSessionResolve({ workspaceRoots: ctx.workspaceRoots }); @@ -348,6 +374,7 @@ export async function renderInstruction(flag, ctx = {}) { buildRewriteBullets(governed, resolved), ) .replace(/\{\{DOCKER_SECTION\}\}/g, buildDockerSection(governed, resolved)) + .replace(/\{\{AGENT_GUARD_SECTION\}\}/g, buildAgentGuardSection(governed)) .replace( /\{\{AUTO_SETUP_STATUS\}\}/g, ctx.autoSetupStatus ? `\n${ctx.autoSetupStatus}\n` : "", diff --git a/plugin/modules/package-resolution/scripts/resolver.mjs b/plugin/modules/package-resolution/scripts/resolver.mjs index 68cda81..9e7acec 100644 --- a/plugin/modules/package-resolution/scripts/resolver.mjs +++ b/plugin/modules/package-resolution/scripts/resolver.mjs @@ -24,6 +24,7 @@ import { import { getPlatformIdentity, authHeader, + isHttpsIdentityUrl, safeErrorMessage, } from "../../core/jf-identity.mjs"; import { PACKAGE_TYPES, repoMatchesPackageType } from "./repo-types.mjs"; @@ -53,6 +54,8 @@ const SESSION = { serverId: null, meta: null, byType: null, + workspaceDeclaredTypes: [], + overlayPreparedFor: null, }; function identityOrNull() { @@ -176,6 +179,10 @@ function normalizeCacheRoot(data) { async function fetchRepoConfig(repoKey, id, deadline) { if (!id) return null; + if (!isHttpsIdentityUrl(id)) { + log.warn("refusing repo verify over a non-HTTPS platform URL", { repoKey }); + return null; + } const url = `${id.url}/artifactory/api/repositories/${encodeURIComponent(repoKey)}`; // Network call on session start (cache miss + verifyRepos) — log at info so a // fresh session's Artifactory calls are visible without enabling debug. @@ -409,7 +416,16 @@ async function ensureSessionResolved( serverIdHint, verifyDeadline = Date.now() + REPO_VERIFY_BUDGET_MS, ) { - const id = identityOrNull(); + const rawId = identityOrNull(); + if (rawId && !isHttpsIdentityUrl(rawId)) { + log.warn("refusing to resolve package URLs over a non-HTTPS platform URL"); + SESSION.serverId = effectiveServerId(serverIdHint, rawId); + SESSION.byType = {}; + SESSION.meta = null; + return; + } + + const id = rawId; const serverId = effectiveServerId(serverIdHint, id); if (SESSION.serverId === serverId && SESSION.byType) return; @@ -431,6 +447,7 @@ async function applyWorkspaceOverlay( workspaceRoots, verifyDeadline = Date.now() + REPO_VERIFY_BUDGET_MS, ) { + SESSION.workspaceDeclaredTypes = []; const roots = workspaceRoots?.length ? workspaceRoots : []; const pick = pickWorkspaceConfigRoot(roots); @@ -457,22 +474,18 @@ async function applyWorkspaceOverlay( } const id = identityOrNull(); + if (id && !isHttpsIdentityUrl(id)) { + log.warn("refusing workspace overlay over a non-HTTPS platform URL"); + return; + } const base = id ? `${id.url}/artifactory` : ""; const pr = loadAgentsConfig().packageResolution; - const adminRepos = pr.defaultGlobalRepos ?? {}; const overridden = []; + const declared = []; const requested = Object.entries(ws.config.repositories).flatMap( ([type, repoKey]) => { if (!repoKey || !PACKAGE_TYPES.includes(type)) return []; - if (!adminRepos[type]) { - log.warn("workspace repo ignored; type is not admin-approved", { - type, - repoKey, - file: pick.configFile, - }); - return []; - } return [{ type, repoKey }]; }, ); @@ -499,14 +512,18 @@ async function applyWorkspaceOverlay( }); continue; } + if (!SESSION.byType) SESSION.byType = {}; SESSION.byType[type] = { type, repoKey, baseUrl: urlFor(type, repoKey, base), }; overridden.push(`${type}:${repoKey}`); + declared.push(type); } + SESSION.workspaceDeclaredTypes = declared; + if (!overridden.length) { log.debug("workspace overlay skipped", { reason: "no-repositories", @@ -531,25 +548,34 @@ async function applyWorkspaceOverlay( /** * Global cache resolve + optional workspace-local overlay (first root with a config file). - * Call once per sessionStart before resolve(type) loops. + * Call once per sessionStart before resolve(type) loops. Eager setup and + * render both call this; the second call is a no-op for the same roots so + * overlay verification is not given a second 5s budget. */ export async function prepareSessionResolve({ serverId, workspaceRoots } = {}) { + const overlayKey = JSON.stringify(workspaceRoots ?? []); + if (SESSION.overlayPreparedFor === overlayKey) return; const verifyDeadline = Date.now() + REPO_VERIFY_BUDGET_MS; await ensureSessionResolved(serverId, verifyDeadline); await applyWorkspaceOverlay(workspaceRoots, verifyDeadline); + SESSION.overlayPreparedFor = overlayKey; } /** - * Governed (handled) package types for this session = admin-declared - * (`defaultGlobalRepos` keys), ordered by PACKAGE_TYPES. Workspace files may - * override only these administrator-approved types. A governed type whose repo - * fails to resolve/verify stays governed (and blocks) rather than falling - * through to a public registry. + * Governed package types for this session = admin-declared + * (`defaultGlobalRepos` keys) UNION workspace keys that actually resolved + * (`.jfrog/local`). Call after prepareSessionResolve so the workspace half is + * populated. Admin types that fail verify stay governed (and block). A + * workspace-only type that fails verify is dropped — not blocked, not + * autoSetup-eligible. * @returns {string[]} */ export function governedPackageTypes() { - const declared = new Set(globalDeclaredTypes()); - return PACKAGE_TYPES.filter((type) => declared.has(type)); + const union = new Set([ + ...globalDeclaredTypes(), + ...(SESSION.workspaceDeclaredTypes ?? []), + ]); + return PACKAGE_TYPES.filter((type) => union.has(type)); } export async function resolve(type, { serverId: serverIdHint } = {}) { @@ -581,6 +607,8 @@ export async function invalidateResolveCache(serverIdHint) { SESSION.serverId = null; SESSION.byType = null; SESSION.meta = null; + SESSION.workspaceDeclaredTypes = []; + SESSION.overlayPreparedFor = null; const serverId = effectiveServerId(serverIdHint); const { data } = await readCacheFile(); const root = normalizeCacheRoot(data); @@ -595,7 +623,7 @@ if (isMain) { const type = process.argv[2]; if (!type) { console.error("usage: node lib/resolver.mjs "); - console.error(" types: npm pypi maven go docker helm nuget"); + console.error(" types: npm pypi maven gradle go docker helm nuget"); process.exit(1); } const result = await resolve(type); diff --git a/plugin/modules/package-resolution/scripts/verify-repo.mjs b/plugin/modules/package-resolution/scripts/verify-repo.mjs new file mode 100644 index 0000000..ea7d117 --- /dev/null +++ b/plugin/modules/package-resolution/scripts/verify-repo.mjs @@ -0,0 +1,194 @@ +// Fail-closed virtual-repo verify for Consent Enable / configure enable. +// +// GET /artifactory/api/repositories/ — confirms virtual + packageType. +// Listing repos is owned by the base jfrog skill, not this module. + +import { + authHeader, + getPlatformIdentity, + isHttpsIdentityUrl, + safeErrorMessage, +} from "../../core/jf-identity.mjs"; +import { createLogger } from "../../core/logger.mjs"; +import { PACKAGE_TYPES, repoMatchesPackageType } from "./repo-types.mjs"; + +const log = createLogger("verify-repo"); + +const VERIFY_TIMEOUT_MS = 45_000; + +/** + * @param {string | undefined | null} type + * @returns {string | null} normalized APR package type or null + */ +export function normalizeAprType(type) { + if (typeof type !== "string") return null; + const key = type.trim().toLowerCase(); + return PACKAGE_TYPES.includes(key) ? key : null; +} + +function testHarnessActive() { + return process.env.JFROG_TEST_HARNESS === "1"; +} + +/** + * Test-only verify override (JFROG_TEST_HARNESS=1): + * JFROG_TEST_VERIFY_REPO=ok + * JFROG_TEST_VERIFY_REPO=fail: + * @returns {object | null} + */ +function testHarnessVerifyOverride({ type, repoKey }) { + if (!testHarnessActive()) return null; + const mode = process.env.JFROG_TEST_VERIFY_REPO; + if (!mode) return null; + if (mode === "ok") { + return { + ok: true, + type, + repoKey, + packageType: type, + rclass: "virtual", + }; + } + if (mode === "fail" || mode.startsWith("fail:")) { + const cause = mode.startsWith("fail:") + ? mode.slice(5) || "not-found" + : "not-found"; + return { ok: false, cause, type, repoKey }; + } + return null; +} + +/** + * Verify one user-provided repo key (fast GET by key). + * @param {{ type: string, repoKey: string }} opts + * @returns {Promise<{ + * ok: boolean, + * cause?: string, + * type?: string, + * repoKey?: string, + * packageType?: string, + * rclass?: string, + * url?: string, + * serverId?: string, + * platformUrl?: string, + * }>} + */ +export async function verifyRepoKey({ type, repoKey }) { + const aprType = normalizeAprType(type); + const key = typeof repoKey === "string" ? repoKey.trim() : ""; + if (!aprType || !key) { + return { ok: false, cause: "bad-args" }; + } + + const harness = testHarnessVerifyOverride({ type: aprType, repoKey: key }); + if (harness) return harness; + + const { identity, cause } = getPlatformIdentity(); + if (!identity) { + return { ok: false, cause: cause || "jf-not-configured" }; + } + + if (!isHttpsIdentityUrl(identity)) { + log.warn("refusing to verify repo over a non-HTTPS platform URL", { + type: aprType, + repoKey: key, + }); + return { + ok: false, + cause: "insecure-url", + type: aprType, + repoKey: key, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + + const authorization = authHeader(identity); + if (!authorization) { + return { ok: false, cause: "jf-unsupported-auth" }; + } + + const url = `${identity.url}/artifactory/api/repositories/${encodeURIComponent(key)}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS); + try { + log.info("verifying repo key", { type: aprType, repoKey: key, url }); + const res = await fetch(url, { + headers: { + Authorization: authorization, + Accept: "application/json", + }, + signal: controller.signal, + }); + if (res.status === 404) { + return { + ok: false, + cause: "not-found", + type: aprType, + repoKey: key, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + if (!res.ok) { + return { + ok: false, + cause: `http-${res.status}`, + type: aprType, + repoKey: key, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + const cfg = await res.json(); + const rclass = String(cfg?.rclass ?? cfg?.type ?? "").toLowerCase(); + if (rclass !== "virtual") { + return { + ok: false, + cause: "not-virtual", + type: aprType, + repoKey: key, + packageType: cfg?.packageType ? String(cfg.packageType) : undefined, + rclass: rclass || undefined, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + // Verify path fail-closed: missing packageType is not a match. + if (!cfg?.packageType || !repoMatchesPackageType(cfg, aprType)) { + return { + ok: false, + cause: "package-type-mismatch", + type: aprType, + repoKey: key, + packageType: cfg?.packageType ? String(cfg.packageType) : undefined, + rclass, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + return { + ok: true, + type: aprType, + repoKey: key, + packageType: String(cfg.packageType), + rclass, + ...(typeof cfg?.url === "string" ? { url: cfg.url } : {}), + serverId: identity.serverId, + platformUrl: identity.url, + }; + } catch (err) { + log.warn("verify repo threw", { + repoKey: key, + error: safeErrorMessage(err), + }); + return { + ok: false, + cause: "unreachable", + type: aprType, + repoKey: key, + }; + } finally { + clearTimeout(timer); + } +} diff --git a/plugin/modules/package-resolution/templates/package-resolution-unconfigured.md b/plugin/modules/package-resolution/templates/package-resolution-unconfigured.md index edc459a..83ddf57 100644 --- a/plugin/modules/package-resolution/templates/package-resolution-unconfigured.md +++ b/plugin/modules/package-resolution/templates/package-resolution-unconfigured.md @@ -1,20 +1,18 @@ # Package Resolution — JFrog Artifactory routing is NOT READY -Your organization routes every package fetch through JFrog Artifactory, but routing -cannot run yet — {{CAUSE_INTRO}}. Package managers still point at -**public** registries until setup completes. +Your organization routes package fetches through JFrog Artifactory, but routing +cannot run yet — {{CAUSE_INTRO}}. Package managers still point at **public** +registries until setup completes. {{GOVERNED_SCOPE}} -**Do not run direct package installs for the governed package managers** while routing is not ready. When asked to -install or pull governed packages, tell the user routing is blocked and complete setup first. Package managers not -listed above are out of scope — handle them normally. +**Do not run direct package installs for the governed package managers** while routing is not ready — there is no +Decision-order rewrite path yet. When asked to install or pull governed packages, tell the user routing is blocked +and complete setup first. Package managers not listed above are out of scope — handle them normally. Do not delegate governed installs to `agent -p` or another child while routing is not ready; refuse before launching and tell the user the **JFrog Artifactory Package Resolution policy** blocks delegation until routing is ready. A child prompt cannot replace trusted Package Resolution injection. ## Read this first -Authoritative procedure: - 1. **`jfrog-setup-package-managers`** skill — **Step 0** for this state. 2. Base **`jfrog`** skill — `references/jfrog-login-flow.md` for server login. @@ -28,24 +26,16 @@ Authoritative procedure: ## After setup — load the routing policy -Once `jf` is configured and `jfrog-setup-package-managers` has bound the package managers, -load the current routing policy by running: +Once `jf` is configured and `jfrog-setup-package-managers` has bound package +managers, load the current routing policy: ```bash {{REFRESH_COMMAND}} ``` -Treat that command's stdout as the authoritative, now-current package-resolution -policy — it **supersedes this "NOT READY" notice** and prints the resolved -Artifactory URLs and hard rules for every configured package type. Then re-issue -the install using those URLs. - -If the command prints nothing, routing is off by config -(`packageResolution.enabled` is not `true`) — an admin opt-in (see Enablement -below). Report that to the user and let them decide. - -## Enablement +Treat that command's stdout as the authoritative policy — it **supersedes this +"NOT READY" notice** and prints resolved Artifactory URLs and hard rules. Then +re-issue the install using those URLs. -Routing is opt-in. Set `packageResolution.enabled: true` in `~/.jfrog/agents-conf.json`. -On first session, if that file is missing, the hook scaffolds it from the shipped -template (`packageResolution.enabled` defaults to `false`). +If the command prints nothing, routing is off +(`packageResolution.enabled` is not `true`) — report that and let the user decide. diff --git a/plugin/modules/package-resolution/templates/package-resolution.md b/plugin/modules/package-resolution/templates/package-resolution.md index e7d6315..5adcd83 100644 --- a/plugin/modules/package-resolution/templates/package-resolution.md +++ b/plugin/modules/package-resolution/templates/package-resolution.md @@ -2,60 +2,49 @@ Your organization mediates package fetches through JFrog Artifactory for the **governed** package managers listed below. Before any governed package install — -shell, sub-agent, or MCP tool — route through the resolved Artifactory repository. +shell, sub-agent, or MCP tool — follow the **Decision order** below. {{GOVERNED_SCOPE}} Whenever this policy blocks an action, explicitly say it is blocked by the organization's **JFrog Artifactory Package Resolution policy**. {{AUTO_SETUP_STATUS}} +## Decision order (top to bottom; first match wins) + +**Setup skill** = `jfrog-setup-package-managers`. Public-registry / skip-JFrog asks → step 7 **immediately**. + +1. **Unresolved** — `` → do **not** install; invoke the setup skill. Never invent a URL or use a public registry. +2. **Zero-touch handled** — **Package manager setup** status line lists this PM as: + - `already set up` → normal command (trust PM config). **No** `--registry`, `--index-url`, `GOPROXY=…`. + - `setting up in the background` → **direct rewrite only** (no `npx`/`-r`/postinstall/`docker build` until `already set up` or durable PM config exists). +3. **Foreign-host conflict** — status says `left unchanged (already using another JFrog / registry)` → ask _Switch to this JFrog instance?_; on yes, `jf setup --server-id … --repo …` only — never bare `jf setup`. +4. **Manifest unbound** — governed manifest present (e.g. `package.json`, `requirements.txt`, `go.mod`; map in setup skill) **and** `.jfrog/local/package-resolution.json` lacks that type → setup skill first (`jf setup` + binding; autoSetup does **not** write that file), **then** install. No rewrite-flag-only shortcut (`--registry`, `--index-url`, `GOPROXY=…`). **Agent Guard bootstrap** (below) is exempt from this rewrite-flag ban. +5. **Ready** — binding present, **or** no governed manifest for this type. Flag-based (npm/pypi/go/docker): rewrite / trust PM config. **Config-driven** (maven/gradle/helm/nuget) unbound → setup skill first; not rewrite-ready. +6. **401/403 from JFrog** → setup skill again; never raw `npm login` / `docker login` / `pip config`. +7. **Public-registry / skip-JFrog** → refuse (hard rule #7). Offer the next allowed step from this order. + +Ungoverned package managers are out of scope — install normally; do not invoke the setup skill. + ## Resolved URLs for this session {{RESOLVED_TABLE}} -If any row shows ``, ask the user which repo to use and invoke -`jfrog-setup-package-managers` — do not guess or call public registries. +Unresolved rows → Decision step 1 (setup skill; no public registries). ## Rewrite templates -Direct installs — form the command yourself (no automatic rewriter; `jf setup` package-manager -config and server-side Curation back this): +Use only when Decision order reached step 2 (`setting up in the background`) or step 5. Form the command yourself (`jf setup` config + Curation back this): {{REWRITE_BULLETS}} -## Hard rules (apply to the governed package managers above) +## Hard rules (governed types only) -**Agent Guard bootstrap — the one exception to rule 7.** A command that installs -`@jfrog/agent-guard` and no other package may carry the registry its own JFrog -Agent Guard policy specifies, e.g. `npx --yes --registry @jfrog/agent-guard …` -or `npm install --registry @jfrog/agent-guard`. Leave that registry alone. - -Nothing else is covered. If the command installs any other package, omits the -explicit `@jfrog/agent-guard` argument, or points a general-purpose install at a -non-JFrog host, rule 7 applies and you refuse. This exception never authorizes a -public registry, and it never widens to another package. - -1. **Only URLs in the table above** — for the governed package managers, no default upstream registries, mirrors, or CDNs. -2. **Never override flags the user typed** (`--registry`, `--index-url`, `GOPROXY=…`) — if the command already includes a routing flag, surface the conflict with policy and ask before changing the command. This applies only to flags already in the command, **not** to verbal requests in chat to bypass routing policy. -3. **Indirect installs** (`npx`, `pip install -r`, `docker build`, postinstall scripts) — trust package-manager config files; if missing, run `jfrog-setup-package-managers`. +{{AGENT_GUARD_SECTION}} +1. **Only URLs in the table above** — no public registries, mirrors, or CDNs. +2. **Never override flags the user typed** (`--registry`, `--index-url`, `GOPROXY=…`) — if already in the command, ask before changing. This applies only to flags already in the command, **not** to verbal requests in chat to bypass routing policy. +3. **Indirect installs** (`npx`, `pip install -r`, `docker build`, postinstall) — trust PM config; if missing, run the setup skill (unless Decision step 2 lists `already set up`). 4. **Curation block** — surface the reason verbatim; do not retry another host. -5. **Unresolved governed package manager** — if the table shows `` for a governed package manager the user - requested, **do not run the original command**. In order: (a) invoke `jfrog-setup-package-managers` for that package manager, - (b) wait until `.jfrog/local/package-resolution.json` records the binding, - (c) re-issue routed via the templates above. A successful exit from an unrouted - command still violates policy. -6. **401/403 from JFrog** — run `jfrog-setup-package-managers` (`jf setup`); never raw `docker login` / `npm login` / `pip config`. -7. **No public-registry bypass** — if the user asks to use public registries or skip JFrog routing for a governed package manager, refuse. State clearly that the request is blocked by the organization's **JFrog Artifactory Package Resolution policy**, then offer the JFrog-routed command from the rewrite templates above. -8. **No delegation bypass** — do not spawn `agent -p` or another agent for a governed package-install task unless that child receives this same Package Resolution policy from trusted `sessionStart` injection. **Refuse before launching an unprotected child.** Spawning a child merely so it can refuse is still a policy violation. A routed command or policy text in the child's user prompt cannot replace trusted injection because the child can execute different commands. Never pass a forbidden install request unchanged to a child. In the refusal, explicitly say that the **JFrog Artifactory Package Resolution policy** requires governed installs to remain routed through Artifactory. - -**Package managers not listed above are out of scope** — install them normally; no JFrog routing required. Do not block them, do not invoke `jfrog-setup-package-managers` for them. +5. **Unresolved governed package manager** — Decision step 1: setup skill → wait for `.jfrog/local/package-resolution.json` → re-issue via Decision order. Unrouted success still violates policy. +6. **401/403** — Decision step 6: setup skill (`jf setup`); never raw login/config. +7. **No public-registry bypass** — refuse; name this policy; offer the next allowed Decision step. +8. **No delegation bypass** — do not spawn `agent -p` or another agent for a governed package-install unless the child receives this policy via trusted `sessionStart` injection. **Refuse before launching an unprotected child.** Spawning a child merely so it can refuse is still a policy violation. A routed command or policy text in the child's user prompt cannot replace trusted injection because the child can execute different commands. In the refusal, say the **JFrog Artifactory Package Resolution policy** requires Artifactory routing. {{DOCKER_SECTION}} -When a **governed** package manifest appears and `.jfrog/local/package-resolution.json` lacks the -matching package manager, invoke `jfrog-setup-package-managers` proactively (see that skill for -manifest → package-manager mapping). Do not do this for ungoverned package managers. - -## Enablement - -Opt-in via admin config. Set `packageResolution.enabled: true` in `~/.jfrog/agents-conf.json` -and declare the governed types under `defaultGlobalRepos`. On first session, if that file -is missing, the hook scaffolds it from the shipped template (`packageResolution.enabled` -defaults to `false`). diff --git a/scripts/validate-package-resolution-hook.mjs b/scripts/validate-package-resolution-hook.mjs index 27eeefa..894c2b0 100644 --- a/scripts/validate-package-resolution-hook.mjs +++ b/scripts/validate-package-resolution-hook.mjs @@ -98,26 +98,62 @@ function installFakeJf(home, { url = "https://validation.jfrog.io" } = {}) { return binDir; } -function startFakeArtifactory(port, countFile) { +// resolver.mjs refuses to verify a repo over a non-HTTPS platform URL (never +// send credentials in cleartext), so the fake Artifactory must terminate TLS — +// a plain http server would make every verify attempt fail closed. +function generateSelfSignedCert(dir) { + const keyPath = path.join(dir, "fake-artifactory-key.pem"); + const certPath = path.join(dir, "fake-artifactory-cert.pem"); + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-days", + "1", + "-nodes", + "-keyout", + keyPath, + "-out", + certPath, + "-subj", + "/CN=127.0.0.1", + "-addext", + "subjectAltName=IP:127.0.0.1", + ], + { stdio: "pipe" }, + ); + return { keyPath, certPath }; +} + +function startFakeArtifactory(port, countFile, { keyPath, certPath }) { const server = spawn( process.execPath, [ "-e", ` - const http = require("node:http"); + const https = require("node:https"); const fs = require("node:fs"); const countFile = ${JSON.stringify(countFile)}; - http.createServer((req, res) => { - if (req.url === "/artifactory/api/repositories/npm-virtual") { - const count = Number(fs.readFileSync(countFile, "utf8") || "0") + 1; - fs.writeFileSync(countFile, String(count)); - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify({ packageType: "npm" })); - return; - } - res.statusCode = 404; - res.end(); - }).listen(${port}, "127.0.0.1"); + https.createServer( + { + key: fs.readFileSync(${JSON.stringify(keyPath)}), + cert: fs.readFileSync(${JSON.stringify(certPath)}), + }, + (req, res) => { + if (req.url === "/artifactory/api/repositories/npm-virtual") { + const count = Number(fs.readFileSync(countFile, "utf8") || "0") + 1; + fs.writeFileSync(countFile, String(count)); + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ packageType: "npm" })); + return; + } + res.statusCode = 404; + res.end(); + }, + ).listen(${port}, "127.0.0.1"); `, ], { stdio: "ignore" }, @@ -314,7 +350,11 @@ function main() { const port = 20000 + (process.pid % 1000); const verifyCountFile = path.join(home, "verify-count"); writeFileSync(verifyCountFile, "0"); - const server = startFakeArtifactory(port, verifyCountFile); + const { keyPath, certPath } = generateSelfSignedCert(home); + const server = startFakeArtifactory(port, verifyCountFile, { + keyPath, + certPath, + }); writeAgentsConf(home, { packageResolution: { enabled: true, @@ -324,7 +364,7 @@ function main() { }); try { const fakeJfBin = installFakeJf(home, { - url: `http://127.0.0.1:${port}`, + url: `https://127.0.0.1:${port}`, }); const context = additionalContextOf( runAdapter(home, { @@ -339,6 +379,8 @@ function main() { JFROG_TEST_HARNESS: "1", JFROG_TEST_IDENTITY_PROBE: "skip", JFROG_AGENT_HOOKS_LOG_FILE: path.join(home, "hook.log"), + // Trust the fake Artifactory's self-signed cert for this run only. + NODE_EXTRA_CA_CERTS: certPath, }), ); if (context.includes("NOT READY")) { @@ -373,6 +415,7 @@ function main() { writeAgentsConf(home, { packageResolution: { enabled: true, + defaultGlobalRepos: { npm: "npm-virtual" }, autoSetup: ["npm"], }, });