diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 1d10efe..da116fe 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jfrog", - "version": "0.1.9", + "version": "0.1.10", "description": "JFrog skills and the JFrog MCP server for Codex \u2014 interact with the JFrog Platform.", "author": { "name": "JFrog", @@ -14,10 +14,12 @@ "codex", "plugin", "skills", - "mcp" + "mcp", + "package-resolution" ], "skills": "./skills/", "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json", "interface": { "displayName": "JFrog", "shortDescription": "JFrog Platform skills for Codex", diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json new file mode 100644 index 0000000..f714109 --- /dev/null +++ b/.github/scripts/sync-modules-vendor.json @@ -0,0 +1,7 @@ +{ + "repo": "JFROG/jfrog-agent-hooks", + "pin": "jfrog-agent-hooks/v0.12.0", + "paths": [ + "modules" + ] +} diff --git a/.github/scripts/sync-modules.mjs b/.github/scripts/sync-modules.mjs new file mode 100644 index 0000000..5103e3a --- /dev/null +++ b/.github/scripts/sync-modules.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Vendors modules bundle from jfrog-agent-hooks into this plugin. +// +// Usage: +// JFROG_AGENT_HOOKS_PATH=/path/to/jfrog-agent-hooks node .github/scripts/sync-modules.mjs +// +// Defaults JFROG_AGENT_HOOKS_PATH to ../jfrog-agent-hooks (sibling clone). +// Reads paths from sync-modules-vendor.json. + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "..", ".."); +const vendorPath = path.join(scriptDir, "sync-modules-vendor.json"); + +async function fileExists(p) { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function copyPath(fromDir, toDir, relativePath) { + const from = path.join(fromDir, relativePath); + const to = path.join(toDir, relativePath); + if (!(await fileExists(from))) { + throw new Error(`path missing in upstream: ${relativePath}`); + } + await fs.rm(to, { recursive: true, force: true }); + await fs.mkdir(path.dirname(to), { recursive: true }); + await fs.cp(from, to, { recursive: true }); + console.log(` ${relativePath} -> ${path.relative(process.cwd(), to)}`); +} + +async function main() { + const vendor = JSON.parse(await fs.readFile(vendorPath, "utf8")); + const paths = vendor.paths; + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error(`${vendorPath} must define a non-empty paths array`); + } + + const hooksRoot = + process.env.JFROG_AGENT_HOOKS_PATH?.trim() || + path.resolve(repoRoot, "..", "jfrog-agent-hooks"); + + if (!(await fileExists(hooksRoot))) { + throw new Error( + `jfrog-agent-hooks not found at ${hooksRoot}. Set JFROG_AGENT_HOOKS_PATH.`, + ); + } + + const destPrefix = (vendor.dest_prefix ?? "").replace(/^\/+|\/+$/g, ""); + const destRoot = destPrefix ? path.join(repoRoot, destPrefix) : repoRoot; + + console.log(`--- sync from ${hooksRoot} (pin: ${vendor.pin ?? "local"}) ---`); + for (const rel of paths) { + await copyPath(hooksRoot, destRoot, rel); + } + console.log("done."); +} + +await main(); diff --git a/README.md b/README.md index 84723e1..6b4ebbf 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The JFrog plugin provides the following capabilities, grouped by component: | **MCP** | JFrog MCP server | Bundled `jfrog` MCP server ([`.mcp.json`](.mcp.json)) at `https:///mcp`; this server signs in via OAuth (`codex mcp login jfrog`), so it needs no API key. | | **Skill** | JFrog Platform | Interact with Artifactory repositories, builds, permissions, users, access tokens, projects, release bundles, and platform administration via the JFrog CLI and REST/GraphQL APIs. Also covers security audits, CVE lookups, and Advanced Security exposure queries. | | **Skill** | Package curation | Check whether npm, Maven, PyPI, Go, and other packages are safe, curated, or allowed, then download them through Artifactory remote caches or curation-aware package managers. | +| **Hook + Skill** | Agent Package Resolution (Preview) | Automatically route packages installed by the AI agent through your organization's JFrog Artifactory, keeping agent-driven installs inside your Curation, Xray, and governance perimeter. | | **Skill** | Agent Guard | Codex manages MCPs through the JFrog Agent Guard. Through the Agent Guard you can discover, install, configure, update, and remove MCP servers from the JFrog AI Catalog approved for your project, and authenticate to remote HTTP MCPs via OAuth, API key, or bearer token. | --- @@ -41,7 +42,10 @@ codex plugin marketplace add jfrog/codex-plugin codex plugin add jfrog@codex-plugin ``` -Browse installed plugins in the Codex TUI with `/plugins`. +Browse installed plugins in the Codex TUI with `/plugins`. Installing the plugin +does **not** trust the SessionStart hook — restart Codex, run `/hooks`, and +trust the JFrog Package Resolution command. ChatGPT **web** does not run hook +scripts. ### Local development @@ -116,9 +120,14 @@ restarting Codex, confirm: JFrog skills appear. See [Discovering and invoking skills](#discovering-and-invoking-skills). 3. **MCP server is connected** — run `codex mcp list` and confirm `jfrog` is connected (after `codex mcp login jfrog`). +4. **SessionStart hook is trusted** — `/hooks` lists the JFrog Package Resolution + command as trusted. Without that, Agent Package Resolution does not inject. +5. **`jf rt ping`** — succeeds against your configured server (required for + routing mode). If any check fails, see [Recovery](#recovery). Setting MCP environment variables -by hand does not repair a failed initialization — re-run `jfrog-init` instead. +by hand does not repair a failed MCP initialization — re-run `jfrog-init` +instead. An untrusted SessionStart hook is a `/hooks` step, not an init failure. --- @@ -130,6 +139,24 @@ by hand does not repair a failed initialization — re-run `jfrog-init` instead. | `jfrog-init` stopped at CLI/auth | Follow the skill prompt (`jf config add`, web login, or token path), then **re-run `jfrog-init`**. | Skip init and only export env vars. | | Placeholder still in `.mcp.json` | Set the host in `/.mcp.json`, run `codex mcp login jfrog`, restart Codex. | Reinstall the plugin when only the host placeholder is wrong. | | Plugin not listed | Re-run `codex plugin add jfrog@codex-plugin` outside Codex, then restart Codex. | Run install commands from inside the Codex TUI. | +| `/hooks` shows the Package Resolution command as untrusted, or no Artifactory routing in a new session | Restart Codex, open `/hooks`, and trust the exact command. A later change to the hook definition requires trust again. | Assume `codex plugin add` approved the hook. Do not use `--dangerously-bypass-hook-trust` as the normal path. | +| ChatGPT web never routes installs | Use Codex CLI or the ChatGPT desktop Codex surface. | Expect hook scripts to run on ChatGPT web. | +| `modules/` missing after a local checkout | Use a published release or re-sync with `JFROG_AGENT_HOOKS_PATH=… node .github/scripts/sync-modules.mjs`. | Hand-edit files under `modules/`. | + +--- + +## Agent Package Resolution (Preview) + +> **Preview Notice:** This feature is in preview and licensed under the Apache License 2.0. For clarity: This software is provided "as-is" without warranty of any kind, and without support obligations or service level commitments. Behavior, APIs, conventions, and structure may change without notice between releases. JFrog makes no guarantees of backward compatibility during the preview release cycle. Use in production environments is at your own risk. + +The plugin can now automatically route the packages your AI agent installs (npm, PyPI, Maven, Go, Docker, Helm, and NuGet) through your organization's JFrog Artifactory instead of public registries. This keeps agent-driven dependency installs inside your organization's governance perimeter. + +Agent Package Resolution is in preview. The shipped template enables it with empty repository bindings (nothing is routed until Consent Enable or an admin adds `defaultGlobalRepos`). To get started: + +- **Users:** see the [User Guide](docs/package-resolution-user-guide.md). +- **Admins:** see the [Admin Guide](docs/package-resolution-admin-guide.md). + +Installing the plugin does not skip `/hooks` trust. ChatGPT **web** does not run the SessionStart hook. --- @@ -170,6 +197,16 @@ If a newly installed skill doesn't show up, restart Codex so it re-scans plugins | "Is this Maven package approved for use?" | Checks curation entitlement and policy for the requested package. | | "Download `requests` via JFrog." | Resolves the package through an Artifactory remote cache or curation-aware package manager. | +### Agent Package Resolution + +When Agent Package Resolution is enabled and configured, no special prompt syntax is required. Ask the agent to install or use a package as you normally would, and the plugin routes supported package operations through your organization's Artifactory. + +| Ask the agent… | What happens | +| -------------------------------------- | ------------------------------------------------------------------------ | +| "Add `lodash` to this project." | Resolves the npm package through the configured Artifactory repository. | +| "Add Excel file import to this app." | The agent selects a suitable package and resolves it through the configured Artifactory repository. | +| "Pull the `alpine` Docker image." | Pulls the image through the configured Artifactory Docker repository. | + ### MCP server management (Agent Guard) | Ask the agent… | What happens | @@ -221,6 +258,19 @@ To pull a newer upstream release into this repo: See [`VENDOR.md`](VENDOR.md) for the full picture. +### Updating the vendored modules + +The `modules/` tree is vendored from GHE `jfrog-agent-hooks` at the pin in +[`.github/scripts/sync-modules-vendor.json`](.github/scripts/sync-modules-vendor.json). +Automated `chore/sync-modules-v*` PRs replace that tree. To refresh locally: + +```bash +JFROG_AGENT_HOOKS_PATH=/path/to/jfrog-agent-hooks node .github/scripts/sync-modules.mjs +``` + +Do not hand-edit files under `modules/`. `hooks/hooks.json` is owned by this +repo and is not part of the vendor slice. + --- ## Releasing diff --git a/VENDOR.md b/VENDOR.md index 78e4c9a..ec06806 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -32,3 +32,26 @@ node scripts/sync-skills.mjs ``` The script reads its sibling [`sync-skills-vendor.json`](scripts/sync-skills-vendor.json), downloads the pinned upstream tarball from `codeload.github.com`, and replaces the directories listed in `paths` (today: `skills/`). + +--- + +# Vendored modules + +The `modules/` bundle is vendored from **jfrog-agent-hooks** (GHE) and committed to `main`. + +| | | +| --- | --- | +| **Repository** | `github.jfrog.info/JFROG/jfrog-agent-hooks` | +| **Pinned release** | see `pin` in [`.github/scripts/sync-modules-vendor.json`](.github/scripts/sync-modules-vendor.json) | + +The bundle contains harness runners (`core/`, `*-session-start.mjs`), the `package-resolution/` capability, and `assets/agents-default-conf.json`. Automated sync PRs (`chore/sync-modules-v*`) update this tree on each `jfrog-agent-hooks` release. + +`hooks/hooks.json` is **not** part of the vendor slice. Sync replaces `modules/` only; this plugin owns the Codex SessionStart assembly (APR only — no Agent Guard / MCP-align scripts). + +## Refreshing modules + +```bash +JFROG_AGENT_HOOKS_PATH=/path/to/jfrog-agent-hooks node .github/scripts/sync-modules.mjs +``` + +The script reads `paths` from `sync-modules-vendor.json` (today: `["modules"]`) and replaces the whole `modules/` tree. After a local refresh, stamp `PKG_VERSION` and the pin with the official copy script if you are matching a Sync Plugins drop. A hand refresh copies bytes only; it does not bump plugin versions. diff --git a/docs/package-resolution-admin-guide.md b/docs/package-resolution-admin-guide.md new file mode 100644 index 0000000..d0f97aa --- /dev/null +++ b/docs/package-resolution-admin-guide.md @@ -0,0 +1,533 @@ +# Agent Package Resolution: Admin Guide (Preview) + +Route AI-assisted package installs through your JFrog Artifactory repositories when developers use the **JFrog plugin** for Cursor, Claude Code, VS Code, or Codex. + +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. + +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)) or this repository's [README](../README.md) for **Codex**. + +> **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 | **Codex:** restart, then `/hooks` and trust the SessionStart command (plugin install does not skip this) | +| 5 | Start a **new agent session** — policy and URLs are injected once per session | + + +### Codex install and hook trust + +Installing or enabling the Codex plugin does **not** approve the SessionStart hook. Codex treats plugin-bundled hooks as non-managed: it skips them until you review and trust the exact command. + +After `codex plugin add jfrog@codex-plugin`: + +1. Restart Codex. +2. Run `/hooks` in the TUI and trust `node "${PLUGIN_ROOT}/modules/codex-session-start.mjs" package-resolution`. +3. Start a **new session**. A later change to that command, `timeout`, `additionalContextLimit`, or `statusMessage` requires trust again. + +`--dangerously-bypass-hook-trust` is for already-vetted automation only. **ChatGPT web** does not run hook scripts — use Codex CLI or the ChatGPT desktop Codex surface. + +The hook command uses `${PLUGIN_ROOT}` (Codex also sets `CLAUDE_PLUGIN_ROOT` for compatibility). Local `make install-codex` from `jfrog-agent-hooks` writes a different command into `~/.codex/hooks.json`; that trust hash does **not** cover the plugin definition. Uninstall the local hook before smoke-testing the plugin so you are not approving two SessionStart entries. + +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, VS Code Copilot, and Codex) — **every supported harness gets 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 +{ + "logLevel": "info", + "packageResolution": { + "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", + "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" + } +} +``` + +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 | + +--- + +## Related documentation + +- [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) +- [Install the JFrog plugin for Codex](../README.md#installation) +- [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 new file mode 100644 index 0000000..9134cad --- /dev/null +++ b/docs/package-resolution-user-guide.md @@ -0,0 +1,137 @@ +# Agent Package Resolution: User Guide (Preview) + +**Audience:** Users of Cursor, Claude Code, VS Code Copilot, or Codex with the JFrog plugin, whether or not you're a professional developer. + +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. + +--- + +## Prerequisite + +The JFrog plugin is installed. That's it; nothing else is required of you up front. + +On **Codex**, installing the plugin does not run the SessionStart hook until you trust it. After `codex plugin add jfrog@codex-plugin`, restart Codex, open `/hooks`, and trust the JFrog Package Resolution command. ChatGPT **web** does not run hook scripts. + +## 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. + +### Server not configured + +- **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. + +### 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", + "pypi": "pypi-virtual" + } + } +} +``` + +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 + +| 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: [github.com/jfrog/codex-plugin/issues](https://github.com/jfrog/codex-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/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..8aeac89 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/modules/codex-session-start.mjs\" package-resolution", + "timeout": 7, + "additionalContextLimit": 4000, + "statusMessage": "Routing package installs through JFrog Artifactory…" + } + ] + } + ] + } +} diff --git a/modules/assets/agents-conf-fingerprints.json b/modules/assets/agents-conf-fingerprints.json new file mode 100644 index 0000000..59a9182 --- /dev/null +++ b/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/modules/assets/agents-default-conf.json b/modules/assets/agents-default-conf.json new file mode 100644 index 0000000..35ceff5 --- /dev/null +++ b/modules/assets/agents-default-conf.json @@ -0,0 +1,11 @@ +{ + "logLevel": "info", + "packageResolution": { + "enabled": true, + "verifyRepos": true, + "cacheTtlDays": 7, + "onboardingPrompt": "auto", + "defaultGlobalRepos": {}, + "autoSetup": [] + } +} diff --git a/modules/claude-session-start.mjs b/modules/claude-session-start.mjs new file mode 100644 index 0000000..9a0f81b --- /dev/null +++ b/modules/claude-session-start.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Claude Code SessionStart hook runner. +// +// Usage: node claude-session-start.mjs +// Example: node claude-session-start.mjs package-resolution +// +// stdout: JSON with hookSpecificOutput.additionalContext. No stdout is a no-op. + +import process from "node:process"; + +import { runCapability } from "./core/run-capability.mjs"; +import { + ensureAgentsConfigScaffold, + agentsConfigLoadWarnings, +} from "./core/agents-config.mjs"; +import { + readStdin, + parseSessionId, + detectHarness, + parseWorkspaceRoots, +} from "./core/io.mjs"; +import { setLogContext, createLogger } from "./core/logger.mjs"; + +const HARNESS_ID = "claude_code"; +const log = createLogger("session-start"); + +/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */ +function formatSessionStartStdout(text) { + if (!text?.trim()) return null; + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: text, + }, + }); +} + +function writeStdout(payload) { + if (payload !== null) process.stdout.write(payload); +} + +function writeNoOp() { + // Claude SessionStart: no stdout on no-op. +} + +async function main() { + const capability = process.argv[2]; + if (!capability) { + writeNoOp(); + return; + } + + const startedAtMs = Date.now(); + const stdinRaw = await readStdin(); + const harness = detectHarness(stdinRaw); + if (harness && harness !== HARNESS_ID) { + setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); + log.warn("harness mismatch; wrong adapter invoked", { + expected: HARNESS_ID, + detected: harness, + adapter: "claude-session-start", + }); + writeNoOp(); + return; + } + const sessionId = parseSessionId(stdinRaw); + const workspaceRoots = parseWorkspaceRoots(stdinRaw); + setLogContext({ ide: HARNESS_ID, sessionId }); + ensureAgentsConfigScaffold(); + for (const w of agentsConfigLoadWarnings()) { + log.warn(w.message, { path: w.path }); + } + const text = await runCapability(capability, { + ide: HARNESS_ID, + sessionId, + workspaceRoots, + startedAtMs, + }); + writeStdout(formatSessionStartStdout(text)); +} + +main().catch(() => { + writeNoOp(); + process.exit(0); +}); diff --git a/modules/codex-session-start.mjs b/modules/codex-session-start.mjs new file mode 100644 index 0000000..544758c --- /dev/null +++ b/modules/codex-session-start.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// OpenAI Codex SessionStart hook runner (installed via jfrog/codex-plugin +// or `make install-codex`). +// +// Usage: node codex-session-start.mjs +// Example: node codex-session-start.mjs package-resolution +// +// stdout: JSON with hookSpecificOutput.additionalContext. "{}" is a no-op. + +import process from "node:process"; + +import { runCapability } from "./core/run-capability.mjs"; +import { + ensureAgentsConfigScaffold, + agentsConfigLoadWarnings, +} from "./core/agents-config.mjs"; +import { + readStdin, + parseSessionId, + detectHarness, + parseWorkspaceRoots, +} from "./core/io.mjs"; +import { setLogContext, createLogger } from "./core/logger.mjs"; + +const HARNESS_ID = "codex"; +const log = createLogger("session-start"); + +/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */ +function formatSessionStartStdout(text) { + if (!text?.trim()) return null; + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: text, + }, + }); +} + +function writeStdout(payload) { + if (payload === null) { + writeNoOp(); + return; + } + process.stdout.write(payload); +} + +function writeNoOp() { + process.stdout.write("{}"); +} + +async function main() { + const capability = process.argv[2]; + if (!capability) { + writeNoOp(); + return; + } + + const startedAtMs = Date.now(); + const stdinRaw = await readStdin(); + const harness = detectHarness(stdinRaw); + if (harness && harness !== HARNESS_ID) { + setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); + log.warn("harness mismatch; wrong adapter invoked", { + expected: HARNESS_ID, + detected: harness, + adapter: "codex-session-start", + }); + writeNoOp(); + return; + } + const sessionId = parseSessionId(stdinRaw); + const workspaceRoots = parseWorkspaceRoots(stdinRaw); + setLogContext({ ide: HARNESS_ID, sessionId }); + ensureAgentsConfigScaffold(); + for (const w of agentsConfigLoadWarnings()) { + log.warn(w.message, { path: w.path }); + } + const text = await runCapability(capability, { + ide: HARNESS_ID, + sessionId, + workspaceRoots, + startedAtMs, + }); + writeStdout(formatSessionStartStdout(text)); +} + +main().catch(() => { + writeNoOp(); + process.exit(0); +}); diff --git a/modules/copilot-session-start.mjs b/modules/copilot-session-start.mjs new file mode 100644 index 0000000..9d0c275 --- /dev/null +++ b/modules/copilot-session-start.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// GitHub Copilot Chat SessionStart hook runner (installed via the VS Code +// Copilot plugin — see jfrog/vscode-plugin). +// +// Usage: node copilot-session-start.mjs +// Example: node copilot-session-start.mjs package-resolution +// +// stdout: JSON with hookSpecificOutput.additionalContext. "{}" is a no-op. + +import process from "node:process"; + +import { runCapability } from "./core/run-capability.mjs"; +import { + ensureAgentsConfigScaffold, + agentsConfigLoadWarnings, +} from "./core/agents-config.mjs"; +import { + readStdin, + parseSessionId, + detectHarness, + parseWorkspaceRoots, +} from "./core/io.mjs"; +import { setLogContext, createLogger } from "./core/logger.mjs"; + +const HARNESS_ID = "copilot"; +const log = createLogger("session-start"); + +/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */ +function formatSessionStartStdout(text) { + if (!text?.trim()) return null; + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: text, + }, + }); +} + +function writeStdout(payload) { + if (payload === null) { + writeNoOp(); + return; + } + process.stdout.write(payload); +} + +function writeNoOp() { + process.stdout.write("{}"); +} + +async function main() { + const capability = process.argv[2]; + if (!capability) { + writeNoOp(); + return; + } + + const startedAtMs = Date.now(); + const stdinRaw = await readStdin(); + const harness = detectHarness(stdinRaw); + if (harness && harness !== HARNESS_ID) { + setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); + log.warn("harness mismatch; wrong adapter invoked", { + expected: HARNESS_ID, + detected: harness, + adapter: "copilot-session-start", + }); + writeNoOp(); + return; + } + const sessionId = parseSessionId(stdinRaw); + const workspaceRoots = parseWorkspaceRoots(stdinRaw); + setLogContext({ ide: HARNESS_ID, sessionId }); + ensureAgentsConfigScaffold(); + for (const w of agentsConfigLoadWarnings()) { + log.warn(w.message, { path: w.path }); + } + const text = await runCapability(capability, { + ide: HARNESS_ID, + sessionId, + workspaceRoots, + startedAtMs, + }); + writeStdout(formatSessionStartStdout(text)); +} + +main().catch(() => { + writeNoOp(); + process.exit(0); +}); diff --git a/modules/core/agent-guard-check.mjs b/modules/core/agent-guard-check.mjs new file mode 100644 index 0000000..de1f52e --- /dev/null +++ b/modules/core/agent-guard-check.mjs @@ -0,0 +1,381 @@ +#!/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"; +import { skillsProductUserAgent } from "./jf-user-agent.mjs"; + +export const SETTINGS_PATH = + "/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled"; +// Self-hosted JPDs serve the same API behind `/bridge-client`. Tried ONLY +// after the root path 404s, so SaaS still costs exactly one request. +export const BRIDGE_CLIENT_PREFIX = "/bridge-client"; +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}')`, + }; +} + +/** Drops the internal `notFound` marker from a fetchSetting() result. */ +function strip({ notFound: _notFound, ...result }) { + return result; +} + +/** + * @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 rootResult = await fetchSetting(root + SETTINGS_PATH, token, { + debug, + fetchFn, + timeoutMs, + }); + if (!rootResult.notFound) return strip(rootResult); + + // Root 404 -> possibly self-hosted. Each attempt gets its OWN timeout + // budget: a reused AbortController would start the retry already spent. + debug( + `Root ${SETTINGS_PATH} returned 404; retrying behind ${BRIDGE_CLIENT_PREFIX}.`, + ); + const bridgeResult = await fetchSetting( + root + BRIDGE_CLIENT_PREFIX + SETTINGS_PATH, + token, + { debug, fetchFn, timeoutMs }, + ); + // Bridge may only UPGRADE the verdict; anything else keeps the root result. + if (bridgeResult.ok || bridgeResult.registryOff) return strip(bridgeResult); + return strip(rootResult); +} + +/** + * One HTTP attempt against a fully-built settings URL. `notFound` marks the + * 404 that triggers the `/bridge-client` retry; callers strip it before + * returning so the public result shape is unchanged. + * + * @param {string} url + * @param {string} token + * @param {{ + * fetchFn: typeof fetch, + * timeoutMs: number, + * debug: (message: string) => void, + * }} opts + */ +async function fetchSetting(url, token, { debug, fetchFn, timeoutMs }) { + 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}`, + "User-Agent": skillsProductUserAgent(), + }, + signal: controller.signal, + }); + if (!response.ok) { + debug(`Settings request returned HTTP ${response.status}.`); + return { + ok: false, + notFound: response.status === 404, + 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/modules/core/agents-config.mjs b/modules/core/agents-config.mjs new file mode 100644 index 0000000..288c290 --- /dev/null +++ b/modules/core/agents-config.mjs @@ -0,0 +1,464 @@ +// Local admin config at ~/.jfrog/agents-conf.json (shipped template: assets/agents-default-conf.json). +// +// Read-only helpers — no network. Session starters call ensureAgentsConfigScaffold() +// before capabilities run so first-time installs get a writable config file. + +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { isSafeRepoKey } from "../package-resolution/scripts/repo-types.mjs"; + +/** modules bundle root (parent of core/ and assets/). */ +const PLUGIN_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const TEMPLATE_PATH = path.join( + PLUGIN_ROOT, + "assets", + "agents-default-conf.json", +); + +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: "" }; + +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 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. + */ +function ensureAgentsConfigScaffoldUnlocked() { + const configPath = agentsConfigPath(); + if (existsSync(configPath)) return { created: false, path: configPath }; + try { + mkdirSync(path.dirname(configPath), { recursive: true }); + 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 { + return statSync(agentsConfigPath()).mtimeMs; + } catch { + return null; + } +} + +function parseAgentsJson(raw) { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function readAgentsConfigRaw() { + const configPath = agentsConfigPath(); + const mtimeMs = getAgentsConfigMtimeMs(); + if ( + memoizedForPath !== configPath || + memoizedMtimeMs !== mtimeMs || + memoizedRaw === undefined + ) { + memoizedRaw = undefined; + memoizedForPath = configPath; + memoizedMtimeMs = mtimeMs; + resetLoadMeta(configPath); + } + if (memoizedRaw !== undefined) return memoizedRaw; + + const userExists = existsSync(configPath); + if (userExists) { + try { + const parsed = parseAgentsJson(readFileSync(configPath, "utf8")); + if (parsed) { + memoizedRaw = parsed; + loadMeta = { source: "user", parseFailed: false, path: configPath }; + return memoizedRaw; + } + loadMeta = { source: "template", parseFailed: true, path: configPath }; + } catch { + loadMeta = { source: "template", parseFailed: true, path: configPath }; + } + } + + try { + memoizedRaw = parseAgentsJson(readFileSync(TEMPLATE_PATH, "utf8")); + if (!userExists) { + loadMeta = { + source: memoizedRaw ? "template" : "missing", + parseFailed: false, + path: configPath, + }; + } + } catch { + memoizedRaw = null; + if (!userExists) + loadMeta = { source: "missing", parseFailed: false, path: configPath }; + } + return memoizedRaw; +} + +/** Call after loadAgentsConfig() — surfaces user-file parse failures. */ +export function getAgentsConfigLoadMeta() { + readAgentsConfigRaw(); + return { ...loadMeta }; +} + +/** @returns {Array<{ message: string, path: string }>} */ +export function agentsConfigLoadWarnings() { + loadAgentsConfig(); + if (!loadMeta.parseFailed) return []; + return [ + { + message: "agents-conf.json unreadable; using shipped template defaults", + path: loadMeta.path, + }, + ]; +} + +/** @returns {object | null} raw section or null */ +export function getAgentsConfigSection(name) { + const config = readAgentsConfigRaw(); + if (!config) return null; + const section = config[name]; + return section && typeof section === "object" ? section : null; +} + +/** @returns {{ logLevel: string, packageResolution: object }} merged with documented defaults */ +export function loadAgentsConfig() { + const file = readAgentsConfigRaw() ?? {}; + const pr = + file.packageResolution && typeof file.packageResolution === "object" + ? file.packageResolution + : {}; + const defaultGlobalRepos = + pr.defaultGlobalRepos && typeof pr.defaultGlobalRepos === "object" + ? normalizeRepoMap(pr.defaultGlobalRepos) + : {}; + + return { + logLevel: normalizeLogLevel(file.logLevel), + packageResolution: { + 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. 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() { + return Object.keys(loadAgentsConfig().packageResolution.defaultGlobalRepos); +} + +/** + * Repo-agnostic "auto setup" policy check for a single package type. + * `autoSetup: true` means all governed types; an array names a subset. + * NOTE: this is a pure policy check — the caller still gates on the type being + * governed + resolved this session. + * @param {string} type + * @returns {boolean} + */ +export function isAutoSetup(type) { + const e = loadAgentsConfig().packageResolution.autoSetup; + if (e === true) return true; + return Array.isArray(e) && e.includes(type); +} + +function normalizeLogLevel(level) { + const s = typeof level === "string" ? level.toLowerCase() : ""; + const allowed = new Set(["silent", "debug", "info", "warn", "error"]); + return allowed.has(s) ? s : DEFAULT_LOG_LEVEL; +} + +function normalizeCacheTtlDays(days) { + if (days === 0) return 0; + if (typeof days !== "number" || !Number.isFinite(days) || days < 0) { + return DEFAULT_CACHE_TTL_DAYS; + } + return Math.floor(days); +} + +/** + * Normalize the `autoSetup` policy: `true` (all governed types) or an + * array of type-name strings. Anything else -> `[]` (nothing eager). Malformed + * array entries (non-strings / blanks) are dropped; whether a named type is + * actually governed is validated later (per-session, where governance is known). + * @returns {true | string[]} + */ +export function normalizeAutoSetup(raw) { + if (raw === true) return true; + if (!Array.isArray(raw)) return []; + const out = []; + for (const t of raw) { + if (typeof t === "string" && t.trim()) out.push(t.trim()); + } + return out; +} + +/** Trim string repo keys; drop empty values. */ +export function normalizeRepoMap(raw) { + if (!raw || typeof raw !== "object") return {}; + const out = {}; + for (const [type, key] of Object.entries(raw)) { + if (isSafeRepoKey(key?.trim())) out[type] = key.trim(); + } + return out; +} diff --git a/modules/core/entry.mjs b/modules/core/entry.mjs new file mode 100644 index 0000000..476d681 --- /dev/null +++ b/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/modules/core/io.mjs b/modules/core/io.mjs new file mode 100644 index 0000000..b377815 --- /dev/null +++ b/modules/core/io.mjs @@ -0,0 +1,164 @@ +// Shared stdin helpers for subprocess-style adapters (Claude, Cursor, VS Code, +// Codex). +// +// Hooks deliver their JSON payload on stdin immediately; in non-hook contexts +// (CI, npm scripts, terminal smoke tests) nothing arrives, so we bail out after +// a short idle window rather than hang. + +import process from "node:process"; + +/** A whole payload has arrived, as opposed to a prefix of one. */ +function isCompletePayload(text) { + let value; + try { + value = JSON.parse(text); + } catch { + return false; // still mid-payload + } + // Objects only: a truncated object never parses, but a truncated number + // does, so `12` arriving out of `1234` must not look finished. + return typeof value === "object" && value !== null; +} + +// Releasing the stream matters as much as reading it. A 'data' listener puts +// stdin in flowing mode, which keeps the handle referenced and the process +// alive even after the hook has written its answer. A caller that holds the +// pipe open would otherwise hang us until the harness kills the process — +// which, on a fail-closed hook, denies the tool call. +// +// The same caller costs us latency even when nothing hangs: waiting out the +// idle window on every preToolUse call added ~60ms to each of the agent's +// shell commands. A hook payload is one JSON object, so once it parses there +// is nothing left to wait for and we stop reading immediately. +export function readStdin({ idleMs = 50 } = {}) { + return new Promise((resolve) => { + if (process.stdin.isTTY) return resolve(""); + let data = ""; + let settled = false; + let idleTimer; + + const onData = (chunk) => { + data += chunk; + if (isCompletePayload(data)) settle(); + else idleTimer.refresh(); + }; + + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(idleTimer); + process.stdin.off("data", onData); + process.stdin.off("end", settle); + process.stdin.off("error", settle); + process.stdin.pause(); + process.stdin.unref?.(); + resolve(data); + }; + + idleTimer = setTimeout(settle, idleMs); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", onData); + process.stdin.on("end", settle); + process.stdin.on("error", settle); + }); +} + +export function parseSessionId(stdinRaw) { + if (!stdinRaw) return undefined; + try { + return JSON.parse(stdinRaw)?.session_id; + } catch { + return undefined; + } +} + +// Claude's documented SessionStart sources. VS Code Copilot documents only +// "new", so the two sets stay disjoint and neither can claim the other's +// sessions. +const CLAUDE_SESSION_SOURCES = new Set([ + "startup", + "resume", + "clear", + "compact", +]); + +// Codex SessionStart reuses Claude's source values (startup/resume/clear/ +// compact). Classify it before those sources or the Codex adapter no-ops. +// Fingerprints are from the official hook schema: transcript under `.codex/` +// or `rollout.jsonl`, and session ids prefixed `thr_`. +function isCodexPayload(p) { + const transcript = p.transcript_path; + if (typeof transcript === "string" && transcript) { + if ( + transcript.includes("/.codex/") || + transcript.endsWith("rollout.jsonl") + ) { + return true; + } + } + return typeof p.session_id === "string" && p.session_id.startsWith("thr_"); +} + +// Positively identify the harness that invoked this hook from its stdin +// payload. Returns "cursor", "copilot", "codex", "claude_code", or null when +// no harness left a fingerprint (no stdin — e.g. terminal smoke tests — or a +// shape none of them own). +// +// Why this matters: Cursor reads sessionStart hooks from BOTH +// ~/.cursor/hooks.json AND ~/.claude/settings.json. Without this, a Cursor +// session fires the Claude adapter too, double-injecting the policy. Each +// adapter uses this to no-op when a different harness invoked it. +// +// Every branch below is a signal exactly one harness documents, and null means +// "can't tell". An adapter is only ever registered by the harness it serves, so +// a payload no harness claims is left to whichever adapter was invoked. +export function detectHarness(stdinRaw) { + if (!stdinRaw) return null; + try { + const p = JSON.parse(stdinRaw); + if (!p) return null; + // Cursor stamps its own version/agent on every hook payload. + if (p.cursor_version || p.agent_type === "cursor") { + return "cursor"; + } + if (p.hook_event_name === "SessionStart") { + // Copilot's documented `new` source is decisive. Current VS Code payloads + // also include a transcript_path, so path presence cannot classify Claude + // before the source is checked. + if (p.source === "new") return "copilot"; + // Codex shares Claude's SessionStart sources — fingerprint first. + if (isCodexPayload(p)) return "codex"; + if (CLAUDE_SESSION_SOURCES.has(p.source)) return "claude_code"; + } + if (isCodexPayload(p)) return "codex"; + // Claude writes a transcript for non-SessionStart hooks too. + if (p.transcript_path) return "claude_code"; + } catch { + // stdin wasn't JSON — can't tell. + } + return null; +} + +/** + * Workspace roots for this hook invocation. + * Cursor: workspace_roots[]. Claude, Codex, and VS Code Copilot: payload cwd. + * Fallback: process.cwd(). + * + * @param {string} [stdinRaw] + * @returns {string[]} + */ +export function parseWorkspaceRoots(stdinRaw) { + if (stdinRaw?.trim()) { + try { + const p = JSON.parse(stdinRaw); + if (Array.isArray(p.workspace_roots) && p.workspace_roots.length) { + return p.workspace_roots.filter((r) => typeof r === "string" && r); + } + if (typeof p.cwd === "string" && p.cwd) return [p.cwd]; + } catch { + // fall through + } + } + + return [process.cwd()]; +} diff --git a/modules/core/jf-identity.mjs b/modules/core/jf-identity.mjs new file mode 100644 index 0000000..6e2413b --- /dev/null +++ b/modules/core/jf-identity.mjs @@ -0,0 +1,484 @@ +// Platform identity — single source of truth for "where is JFrog and how do +// we auth to it?". Used by feature-flag.mjs and resolver.mjs. +// +// Identity ALWAYS comes from `jf config`. `jf config export [serverId]` returns +// base64(JSON({ url, accessToken, user, password, serverId, ... })) for the +// chosen (or default) server. A usable identity needs a platform `url` plus a +// credential: an access token (Bearer) OR username + password / API key +// (Basic). Access token wins when both are present (mirrors `jf setup`). +// +// After credentials parse, an optional readiness probe (Artifactory ping) +// rejects expired/revoked/unreachable credentials so the feature flag can +// fall into pending instead of "routing with empty repos". +// +// If `jf` is not on PATH, has no configured servers, or the chosen server has +// no usable credential (e.g. SSH-key-only), identity is null and the feature +// flag falls into the `missing-identity` path (hook goes no-op, fail closed). +// +// Config export is cached per process. Probe results are cached separately +// (async) so feature-flag can await readiness without making getPlatformIdentity +// async. + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +import { createLogger } from "./logger.mjs"; +import { skillsProductUserAgent } from "./jf-user-agent.mjs"; + +const log = createLogger("jf-identity"); + +/** Wire-format cause codes for getPlatformIdentity() / pending remediation. */ +export const IdentityCause = Object.freeze({ + OK: "ok", + JF_NOT_INSTALLED: "jf-not-installed", + JF_NOT_CONFIGURED: "jf-not-configured", + /** Server present but credential shape unusable (e.g. SSH-key-only). */ + JF_UNSUPPORTED_AUTH: "jf-unsupported-auth", + /** Credential present but Artifactory rejected it (401/403). */ + JF_AUTH_FAILED: "jf-auth-failed", + /** Probe timed out / network / non-auth HTTP failure. */ + JF_UNREACHABLE: "jf-unreachable", + /** Platform URL is not https — refuse to send credentials in cleartext. */ + INSECURE_URL: "insecure-url", +}); + +/** + * Credentials must never travel in cleartext. `jf` accepts http:// servers; + * callers that send Authorization headers must gate on https first. + * @param {{ url?: string } | string | null | undefined} identityOrUrl + */ +export function isHttpsIdentityUrl(identityOrUrl) { + try { + const raw = + typeof identityOrUrl === "string" + ? identityOrUrl + : (identityOrUrl?.url ?? ""); + return new URL(String(raw)).protocol === "https:"; + } catch { + return false; + } +} +const PROBE_TIMEOUT_MS = 3_000; + +// Module-scope cache. Keyed by the requested serverId hint (`undefined` +// means "whatever jf considers default"). Stores the full resolved object, +// including null when jf config produced nothing usable. +const CACHE = new Map(); +// Probe results are cached for the process lifetime (each hook is a fresh +// process, so there's nothing to expire within one). Both ok and non-ok +// results are memoized so feature-flag + resolver share one round-trip. +/** @type {Map} */ +const PROBE_CACHE = new Map(); + +function normalizeUrl(u) { + if (!u) return ""; + return String(u).replace(/\/+$/, ""); +} + +function jfConfigIdentity(serverId) { + const args = ["config", "export"]; + if (serverId) args.push(serverId); + + let result; + try { + result = spawnSync("jf", args, { + encoding: "utf8", + timeout: 2000, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + log.debug("jf spawn threw", { error: err?.message ?? String(err) }); + return { identity: null, cause: IdentityCause.JF_NOT_INSTALLED }; + } + + if (result.error) { + log.debug("jf spawn error", { + code: result.error.code, + message: result.error.message, + }); + return { identity: null, cause: IdentityCause.JF_NOT_INSTALLED }; + } + if (result.status !== 0) { + log.debug("jf config export non-zero exit", { + status: result.status, + stderr: (result.stderr || "").trim().slice(0, 200), + }); + return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; + } + + const blob = (result.stdout || "").trim(); + if (!blob) { + log.debug("jf config export returned empty stdout"); + return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; + } + + let parsed; + try { + const json = Buffer.from(blob, "base64").toString("utf8"); + parsed = JSON.parse(json); + } catch (err) { + log.warn("jf config export blob not decodable", { + error: err?.message ?? String(err), + }); + return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; + } + + const url = normalizeUrl(parsed?.url); + const token = parsed?.accessToken ?? ""; + const user = parsed?.user ?? ""; + const password = parsed?.password ?? ""; + const resolvedServerId = parsed?.serverId ?? serverId ?? null; + + if (!url) { + log.debug("jf config export missing url", { + serverId: resolvedServerId, + hasUrl: false, + hasToken: Boolean(token), + hasUser: Boolean(user), + hasPassword: Boolean(password), + }); + return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; + } + + // Access token wins when both are present (mirrors jf setup precedence). + let auth = null; + if (token) { + auth = { kind: "bearer", token }; + } else if (user && password) { + auth = { kind: "basic", user, password }; + } + + if (!auth) { + log.debug("jf config export has url but no usable credential", { + serverId: resolvedServerId, + hasUrl: true, + hasToken: Boolean(token), + hasUser: Boolean(user), + hasPassword: Boolean(password), + }); + return { identity: null, cause: IdentityCause.JF_UNSUPPORTED_AUTH }; + } + + log.debug("jf config export identity accepted", { + serverId: resolvedServerId, + hasUrl: true, + authKind: auth.kind, + }); + + return { + identity: { + url, + serverId: resolvedServerId, + source: "jf-config", + auth, + }, + cause: IdentityCause.OK, + }; +} + +/** + * HTTP Authorization header value for Artifactory API calls, or null. + * Rejects credentials with CR/LF so Node never throws a header error that + * echoes the secret in `err.message`. + */ +export function authHeader(identity) { + const auth = identity?.auth; + if (!auth) return null; + if (auth.kind === "bearer") { + const token = String(auth.token ?? ""); + if (!token || /[\r\n]/.test(token)) return null; + return `Bearer ${token}`; + } + if (auth.kind === "basic") { + const user = String(auth.user ?? ""); + const password = String(auth.password ?? ""); + if (!user || !password || /[\r\n]/.test(user) || /[\r\n]/.test(password)) { + return null; + } + return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; + } + return null; +} + +/** Strip credential material from error strings before logging. */ +export function safeErrorMessage(err) { + const raw = err?.message ?? String(err ?? ""); + return raw + .replace(/Bearer\s+\S+/gi, "Bearer ") + .replace(/Basic\s+\S+/gi, "Basic "); +} + +function probeCacheKey(identity) { + const auth = identity?.auth; + if (!auth) return "none"; + const url = identity.url ?? ""; + if (auth.kind === "bearer") { + const digest = createHash("sha256") + .update(`bearer\0${auth.token ?? ""}`) + .digest("hex") + .slice(0, 16); + return `${url}|bearer|${digest}`; + } + const digest = createHash("sha256") + .update(`basic\0${auth.user ?? ""}\0${auth.password ?? ""}`) + .digest("hex") + .slice(0, 16); + return `${url}|basic|${digest}`; +} + +/** Test hooks only apply when the unit/integration harness sets this. */ +function testHarnessActive() { + return process.env.JFROG_TEST_HARNESS === "1"; +} + +function syntheticProbeResult() { + if (!testHarnessActive()) return null; + const mode = process.env.JFROG_TEST_IDENTITY_PROBE; + if (!mode || mode === "skip") return null; + if (mode === "ok") return { ok: true, cause: IdentityCause.OK }; + if (mode === "401" || mode === "403" || mode === "auth-failed") { + return { ok: false, cause: IdentityCause.JF_AUTH_FAILED }; + } + if (mode === "error" || mode === "unreachable") { + return { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } + return null; +} + +/** + * Probe Artifactory with the resolved credentials. Fail-closed: any non-OK + * response or network error means the identity is not ready for routing. + * + * Test hooks (require `JFROG_TEST_HARNESS=1` — never honored in production): + * JFROG_TEST_IDENTITY_PROBE=skip — do not probe; treat as ok + * ok / 401 / error — synthetic results + * + * Production kill switch: `JF_AGENT_IDENTITY_PROBE=0` skips the probe. + * + * @param {object | null} identity + * @returns {Promise<{ ok: boolean, cause: string }>} + */ +export async function probePlatformIdentity(identity) { + if (!identity) { + return { ok: false, cause: IdentityCause.JF_NOT_CONFIGURED }; + } + + const synthetic = syntheticProbeResult(); + if (synthetic) return synthetic; + + if (testHarnessActive() && process.env.JFROG_TEST_IDENTITY_PROBE === "skip") { + return { ok: true, cause: IdentityCause.OK }; + } + + if (!isHttpsIdentityUrl(identity)) { + log.warn("refusing identity probe over a non-HTTPS platform URL"); + const result = { ok: false, cause: IdentityCause.INSECURE_URL }; + const keyEarly = probeCacheKey(identity); + PROBE_CACHE.set(keyEarly, result); + return result; + } + + if (process.env.JF_AGENT_IDENTITY_PROBE === "0") { + return { ok: true, cause: IdentityCause.OK }; + } + + const key = probeCacheKey(identity); + const cached = PROBE_CACHE.get(key); + if (cached) { + return { ok: cached.ok, cause: cached.cause }; + } + + const authorization = authHeader(identity); + if (!authorization) { + const result = { ok: false, cause: IdentityCause.JF_UNSUPPORTED_AUTH }; + PROBE_CACHE.set(key, result); + return result; + } + + // Auth-required endpoint: `system/ping` is anonymous-capable, so a + // revoked/expired token would still return 200 and wrongly pass readiness. + // `system/version` requires an authenticated (non-anonymous) caller. + const pingUrl = `${identity.url}/artifactory/api/system/version`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + /** @type {{ ok: boolean, cause: string }} */ + let result; + try { + const res = await fetch(pingUrl, { + method: "GET", + headers: { + Authorization: authorization, + "User-Agent": skillsProductUserAgent(), + }, + signal: controller.signal, + }); + if (res.status === 401 || res.status === 403) { + result = { ok: false, cause: IdentityCause.JF_AUTH_FAILED }; + } else if (!res.ok) { + result = { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } else { + result = { ok: true, cause: IdentityCause.OK }; + } + } catch (err) { + log.debug("identity probe failed", { + url: pingUrl, + error: safeErrorMessage(err), + }); + result = { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } finally { + clearTimeout(timer); + } + + log.debug("identity probe result", { + url: identity.url, + ok: result.ok, + cause: result.cause, + }); + PROBE_CACHE.set(key, result); + return result; +} + +/** + * Config-only identity (sync). Does not probe reachability. + * @returns {{ identity: object | null, cause: string }} + */ +export function getPlatformIdentity() { + const hint = undefined; + if (CACHE.has(hint)) return CACHE.get(hint); + + const status = jfConfigIdentity(hint); + if (status.identity) { + log.debug("identity from jf-config", { + serverId: status.identity.serverId, + url: status.identity.url, + authKind: status.identity.auth?.kind, + }); + } else { + log.debug("no platform identity", { cause: status.cause }); + } + CACHE.set(hint, status); + return status; +} + +/** + * Config identity + readiness probe. Prefer this from async session paths + * (feature-flag) so dead tokens fail closed to pending. + * @returns {Promise<{ identity: object | null, cause: string }>} + */ +export async function getReadyPlatformIdentity() { + const status = getPlatformIdentity(); + if (!status.identity) return status; + + const probe = await probePlatformIdentity(status.identity); + if (probe.ok) return status; + + // Rejected / structurally-unusable credentials are a stable fact → fail + // closed to pending so we don't inject "routing" with an unusable identity. + if ( + probe.cause === IdentityCause.JF_AUTH_FAILED || + probe.cause === IdentityCause.JF_UNSUPPORTED_AUTH || + probe.cause === IdentityCause.INSECURE_URL + ) { + log.debug("identity not ready after probe", { cause: probe.cause }); + return { identity: null, cause: probe.cause }; + } + + // Transient failure (timeout / network / 5xx): keep routing best-effort + // rather than downgrading a healthy setup to pending on a blip. The resolver + // already fails safe per-repo (keeps prior cache, skips empty writes). + log.warn("identity probe unreachable — routing best-effort", { + cause: probe.cause, + }); + return status; +} + +/** Test-only — reset module caches between in-process scenarios. */ +export function clearPlatformIdentityCache() { + CACHE.clear(); + PROBE_CACHE.clear(); +} + +export function identityLabel(identity) { + if (!identity) return "none"; + return identity.serverId ? `jf-config:${identity.serverId}` : "jf-config"; +} + +/** Redact credential material for CLI / harness stdout (keeps kind + user). */ +export function redactIdentity(identity) { + if (!identity) return null; + const auth = identity.auth; + if (!auth) return { ...identity, auth: null }; + if (auth.kind === "bearer") { + return { + ...identity, + auth: { + kind: "bearer", + token: auth.token ? `<${auth.token.length} chars>` : "", + }, + }; + } + return { + ...identity, + // Preserve the real kind — redactIdentity is exported and the harness may + // pass shapes other than "basic"; reporting them all as "basic" misleads. + auth: { + kind: auth.kind ?? "unknown", + user: auth.user ?? "", + password: auth.password ? `<${auth.password.length} chars>` : "", + }, + }; +} + +function noIdentityHint(cause) { + if (cause === IdentityCause.JF_NOT_INSTALLED) { + return "`jf` is not installed. Install the JFrog CLI, then run `jf config add`."; + } + if (cause === IdentityCause.JF_UNSUPPORTED_AUTH) { + return ( + "Configured server auth method is not supported. Use an access token " + + "or username + password / API key (`jf config add`)." + ); + } + if (cause === IdentityCause.JF_AUTH_FAILED) { + return ( + "Configured credentials were rejected by Artifactory (expired, revoked, " + + "or wrong). Refresh with `jf config add` / re-login." + ); + } + if (cause === IdentityCause.JF_UNREACHABLE) { + return ( + "Artifactory did not respond to a readiness probe. Check network / " + + "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)." + ); +} + +const isMain = import.meta.url === `file://${process.argv[1]}`; +if (isMain) { + const labelOnly = process.argv.includes("--label"); + const { identity, cause } = getPlatformIdentity(); + if (labelOnly) { + if (!identity) { + console.log("none"); + process.exit(0); + } + console.log(`${identityLabel(identity)}\t${identity.url}`); + process.exit(0); + } + if (!identity) { + console.error(`No platform identity (${cause}). ${noIdentityHint(cause)}`); + process.exit(2); + } + console.log(JSON.stringify(redactIdentity(identity), null, 2)); +} diff --git a/modules/core/jf-user-agent.mjs b/modules/core/jf-user-agent.mjs new file mode 100644 index 0000000..5b25143 --- /dev/null +++ b/modules/core/jf-user-agent.mjs @@ -0,0 +1,341 @@ +// 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 host env (same order as skills detect_host_client / CLI +// detectClient), then adapter default (cursor→cursor, copilot→vscode). +// Inherited TERM_PROGRAM=vscode is omitted; known terminals map to short +// names. CURSOR_VERSION is hook-only (sessionStart). +// +// 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.12.0"; + +/** Product UA for plugin `fetch()` (no trigger, no jfrog-cli-go). */ +export function skillsProductUserAgent() { + return `jfrog-skills/${PKG_VERSION}`; +} + +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; +} + +const IDE_TOOL = { + claude_code: "claude", + cursor: "cursor", + copilot: "copilot", + codex: "codex", +}; +// Fallback window when no host env proves otherwise: the adapter only ships for +// these two, and each has a default home. +const IDE_CLIENT = { + cursor: "cursor", + copilot: "vscode", +}; + +// The window is read from env the *editor* owns, because the same agent runs in +// IntelliJ, Zed and VS Code. Order matters: every VS Code fork copies the +// VSCODE_* names verbatim, so forks must resolve before anything says "vscode". +// Mirrors detectClient in jfrog-cli-core so both layers agree on the wire value. +const ASKPASS_ENV_VARS = ["VSCODE_GIT_ASKPASS_MAIN", "VSCODE_GIT_ASKPASS_NODE"]; +const COPILOT_VSCODE_PLUGIN_ALIAS = "github_copilot_vscode_agent"; + +const TERMINAL_NAME_ALIASES = { + "iterm.app": "iterm", + iterm: "iterm", + apple_terminal: "terminal", + warpterminal: "warp", + warp: "warp", + tmux: "tmux", + wezterm: "wezterm", + alacritty: "alacritty", + kitty: "kitty", + ghostty: "ghostty", + hyper: "hyper", +}; + +function normalizeAskpassPath(raw) { + return String(raw || "") + .toLowerCase() + .replaceAll("\\", "/"); +} + +// VSCODE_GIT_ASKPASS_* embeds the application name (…/Cursor.app/…). +// GIT_ASKPASS is generic git and is excluded. +function askpassPathContains(env, app) { + const needle = String(app).toLowerCase(); + return ASKPASS_ENV_VARS.some((key) => { + const p = normalizeAskpassPath(env[key]); + if (!p) return false; + return ( + p.includes(`/${needle}.app`) || + p.includes(`/${needle}/resources`) || + p.includes(`/.${needle}-server`) + ); + }); +} + +function askpassLooksLikeStockVSCode(env) { + return ASKPASS_ENV_VARS.some((key) => { + const p = normalizeAskpassPath(env[key]); + if (!p) return false; + return ( + p.includes("/visual studio code") || + p.includes("/microsoft vs code") || + p.includes("/.vscode-server") || + p.includes("/code/resources") + ); + }); +} + +function foldAgentName(raw) { + if (raw == null || raw === "") return ""; + const name = String(raw).trim().toLowerCase(); + const i = name.indexOf("@"); + return i >= 0 ? name.slice(0, i) : name; +} + +function isCopilotVSCodePluginAlias(env) { + return ( + foldAgentName(env.AI_AGENT) === COPILOT_VSCODE_PLUGIN_ALIAS || + foldAgentName(env.AGENT) === COPILOT_VSCODE_PLUGIN_ALIAS + ); +} + +function canonicalTerminalName(raw) { + let name = sanitizeToken(raw); + if (!name) return undefined; + if (TERMINAL_NAME_ALIASES[name]) return TERMINAL_NAME_ALIASES[name]; + name = name.replace(/\.app$/, ""); + if (TERMINAL_NAME_ALIASES[name]) return TERMINAL_NAME_ALIASES[name]; + // Unmapped values (including inherited TERM_PROGRAM=vscode) stay empty. + return undefined; +} + +function fallbackTerminalName(env) { + if (env.TMUX) return "tmux"; + if (env.WT_SESSION) return "windows-terminal"; + if (env.TERM === "xterm-ghostty") return "ghostty"; + if (env.KITTY_WINDOW_ID) return "kitty"; + if (env.ALACRITTY_LOG) return "alacritty"; + return undefined; +} + +function hostTerminalName(env) { + return canonicalTerminalName(env.TERM_PROGRAM) || fallbackTerminalName(env); +} + +/** + * Env keys `hostClientFromEnv` reads. Tests must strip these so the developer + * IDE / tmux / TERM_PROGRAM does not leak into assertions. + * Keep in lockstep with this function and skills `detect_host_client`. + * `CURSOR_VERSION` is hook-only; skills do not read it. + */ +export const HOST_WINDOW_ENV_KEYS = Object.freeze([ + "ZED_TERM", + "TERMINAL_EMULATOR", + "CURSOR_TRACE_ID", + "CURSOR_VERSION", + "VSCODE_GIT_ASKPASS_MAIN", + "VSCODE_GIT_ASKPASS_NODE", + "WINDSURF_CASCADE_TERMINAL", + "ANTIGRAVITY_AGENT", + "TRAE_AI_SHELL_ID", + "VisualStudioVersion", + "COPILOT_AGENT", + "AI_AGENT", + "AGENT", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_IS_COWORK", + "TERM_PROGRAM", + "TMUX", + "WT_SESSION", + "TERM", + "KITTY_WINDOW_ID", + "ALACRITTY_LOG", +]); + +/** + * Host editor window from editor-owned env; undefined when unproven. + * Same order as skills `detect_host_client` (plus `CURSOR_VERSION`). + * @param {NodeJS.ProcessEnv} [env] + * @returns {string | undefined} + */ +export function hostClientFromEnv(env = process.env) { + if (env.ZED_TERM) return "zed"; + if (env.TERMINAL_EMULATOR === "JetBrains-JediTerm") return "jetbrains"; + if (env.CURSOR_TRACE_ID || askpassPathContains(env, "cursor")) + return "cursor"; + // Hook-only: Cursor IDE sets CURSOR_VERSION on sessionStart; CLI uses + // TRACE_ID / askpass and never treats CURSOR_AGENT as the window. + if (env.CURSOR_VERSION) return "cursor"; + if (env.WINDSURF_CASCADE_TERMINAL || askpassPathContains(env, "windsurf")) { + return "windsurf"; + } + if (env.ANTIGRAVITY_AGENT || askpassPathContains(env, "antigravity")) { + return "antigravity"; + } + if (env.TRAE_AI_SHELL_ID || askpassPathContains(env, "trae")) return "trae"; + if ( + askpassPathContains(env, "vscodium") || + askpassPathContains(env, "codium") + ) { + return "codium"; + } + if (env.VisualStudioVersion) return "visualstudio"; + if (askpassLooksLikeStockVSCode(env)) return "vscode"; + if (env.COPILOT_AGENT === "1" || isCopilotVSCodePluginAlias(env)) { + return hostTerminalName(env) || "vscode"; + } + if (env.CLAUDE_CODE_CHILD_SESSION || env.CLAUDE_CODE_IS_COWORK) + return "claude"; + return hostTerminalName(env); +} + +/** + * Adapter `ctx.ide` → wire tool/client. Unknown ide omits both (never "unknown"). + * The window comes from host env when proven, so Copilot in IntelliJ is not + * reported as vscode; the adapter default applies only as a fallback. + * @param {string | undefined} ide + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ tool?: string, client?: string }} + */ +export function axesFromAdapterIde(ide, env = process.env) { + if (!ide) return {}; + const tool = IDE_TOOL[ide]; + if (!tool) return {}; + const client = hostClientFromEnv(env) || IDE_CLIENT[ide]; + return { tool, ...(client ? { client } : {}) }; +} + +/** + * Direct sessionStart (print-policy / test harness) has no adapter `ctx.ide`. + * Strong env only — never CLAUDECODE, CLAUDE_PROJECT_DIR, or CURSOR_PROJECT_DIR. + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ tool?: string, client?: string }} + */ +export function inferDirectSessionAxes(env = process.env) { + const client = hostClientFromEnv(env); + if (env.CURSOR_AGENT || env.CURSOR_VERSION) { + return { tool: "cursor", ...(client ? { client } : {}) }; + } + if (env.CLAUDE_CODE_CHILD_SESSION) { + return { tool: "claude", ...(client ? { client } : {}) }; + } + if (env.COPILOT_CLI) { + return { tool: "copilot", ...(client ? { client } : {}) }; + } + if (env.COPILOT_AGENT === "1") { + return { tool: "copilot", ...(client ? { client } : {}) }; + } + if (env.CODEX_HOME || env.CODEX_API_KEY) { + return { tool: "codex", ...(client ? { client } : {}) }; + } + return {}; +} + +/** + * Adapter stamp when `ctx.ide` is set; otherwise print-policy inference. + * @param {{ ide?: string }} [ctx] + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ tool?: string, client?: string }} + */ +export function axesForSessionStart(ctx = {}, env = process.env) { + if (ctx.ide) return axesFromAdapterIde(ctx.ide, env); + return inferDirectSessionAxes(env); +} + +/** + * Axes present on the hook process itself (not invented, not model). + * @param {NodeJS.ProcessEnv} [env] + * @param {{ tool?: string, client?: 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(opts.client) || + sanitizeToken(env.JFROG_APR_UA_CLIENT) || + undefined; + return { tool, client }; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {{ tool?: string, client?: 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, client?: 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/modules/core/logger.mjs b/modules/core/logger.mjs new file mode 100644 index 0000000..77682b2 --- /dev/null +++ b/modules/core/logger.mjs @@ -0,0 +1,210 @@ +// Shared logger — every hook, resolver call, and feature-flag check writes here. +// +// Log file: ~/.jfrog/logs/agent-hooks.log +// Format: [component] k1=v1 k2=v2 ... +// +// One line per event, append-only, sync writes so short-lived hook processes +// flush before exit. Tail with `make logs` / `tail -F ~/.jfrog/logs/agent-hooks.log`. +// +// Errors from the logger itself are swallowed — a misbehaving log MUST NOT +// break the hook (otherwise the agent session breaks). +// +// Log level: `logLevel` in ~/.jfrog/agents-conf.json (default info). +// Undocumented; for test isolation only — not a customer-facing control: +// JFROG_AGENT_HOOKS_LOG_FILE overrides the log path. +// +// Levels: +// silent no output at all +// debug step-by-step internals (resolver probes, feature-flag detail) +// info user-visible events (hook fired, rewrite applied) +// event one-line header + indented fields (hook summaries; easy to scan) +// warn recoverable issues (unresolved repo, conflict, fallback) +// error unexpected failures (caught exceptions, IO errors) + +import { mkdirSync, appendFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { randomBytes } from "node:crypto"; + +import { getGlobalLogLevel } from "./agents-config.mjs"; + +function defaultLogFile() { + return path.join(homedir(), ".jfrog", "logs", "agent-hooks.log"); +} + +function logFile() { + return process.env.JFROG_AGENT_HOOKS_LOG_FILE || defaultLogFile(); +} + +// `silent` is a sentinel above every numeric level — nothing matches it. +const LEVELS = { + debug: 10, + info: 20, + event: 25, + warn: 30, + error: 40, + silent: 1000, +}; + +let minLevelResolved = false; +let minLevel = LEVELS.info; +let disabled = false; + +function resolveMinLevel() { + if (minLevelResolved) return; + minLevelResolved = true; + const envLevel = getGlobalLogLevel(); + minLevel = LEVELS[envLevel] ?? LEVELS.info; + disabled = minLevel >= LEVELS.silent; +} + +// Short trace id per process — lets you correlate a single hook invocation's +// multi-line output (resolver + feature flag + outcome). +const TRACE_ID = randomBytes(4).toString("hex"); + +// Per-process context that imported modules inherit. The session hook +// (inject-instructions) calls setLogContext({ ide, sessionId }) after +// detecting them, so feature-flag and resolver log lines pick up the same +// IDE / session tag automatically. +const CONTEXT = {}; +export function setLogContext(ctx) { + if (!ctx) return; + for (const [k, v] of Object.entries(ctx)) { + if (v !== undefined && v !== null) CONTEXT[k] = v; + } +} + +let ensuredDir = false; +let ensuredDirFor = ""; +function ensureDir() { + const file = logFile(); + if (ensuredDir && ensuredDirFor === file) return; + try { + mkdirSync(path.dirname(file), { recursive: true }); + ensuredDir = true; + ensuredDirFor = file; + } catch { + // ignore — write attempt below will also swallow + } +} + +// Tags we promote to fixed-width bracket prefixes for scannability. +// Everything else in kv goes to the tail as key=value. +const PREFIX_TAGS = ["ide", "sessionId", "trace"]; + +// Column widths — every line uses these exactly so brackets line up across +// the file. Sized for current values with a small safety margin; any value +// longer than its column is truncated with an ellipsis by `fitCol` so a +// future long component / IDE name can never silently break alignment. +// +// COL_LEVEL — log level inside two spaces (e.g. "EVENT", "DEBUG") +// COL_COMPONENT — "[component]" bracketed, e.g. "[session-policy]" +// COL_IDE — inside the [...] (the brackets themselves are added later) +// COL_SESSION — "sess:<8 hex>", inside [...] +// COL_TRACE — "trace:<8 hex>", inside [...] +const COL_LEVEL = 5; +const COL_COMPONENT = 20; +const COL_IDE = 12; +const COL_SESSION = 13; // "sess:" (5) + 8-char shortId +const COL_TRACE = 14; // "trace:" (6) + 8-char shortId + +function fitCol(s, width) { + if (s.length === width) return s; + if (s.length < width) return s.padEnd(width); + // Truncate with an ellipsis so overflow is visible but doesn't break the + // column. (Single char ellipsis keeps width exact.) + return s.slice(0, width - 1) + "…"; +} + +function shortId(s) { + if (!s) return ""; + return String(s).split("-")[0].slice(0, 8); +} + +function formatKV(kv) { + if (!kv) return ""; + const parts = []; + for (const [k, v] of Object.entries(kv)) { + if (v === undefined || v === null) continue; + const s = typeof v === "string" ? v : JSON.stringify(v); + const needsQuote = /[\s="']/.test(s); + const escaped = s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + parts.push(`${k}=${needsQuote ? `"${escaped}"` : escaped}`); + } + return parts.length ? " " + parts.join(" ") : ""; +} + +function formatKVLines(kv, indent = " ") { + if (!kv) return ""; + const lines = []; + for (const [k, v] of Object.entries(kv)) { + if (v === undefined || v === null) continue; + const s = typeof v === "string" ? v : JSON.stringify(v); + lines.push(`${indent}${k}: ${s}`); + } + return lines.length ? `\n${lines.join("\n")}` : ""; +} + +function bracketPrefixes(kv) { + // Bracketed columns in fixed order: [ide] [sess:xxxx] [trace:xxxx] + // Each inner value is padded/truncated to a fixed width by fitCol so the + // brackets themselves always land at the same byte column. + const ide = fitCol(kv.ide ?? "-", COL_IDE); + const sess = fitCol(`sess:${shortId(kv.sessionId) || "-"}`, COL_SESSION); + const trace = fitCol(`trace:${kv.trace || "-"}`, COL_TRACE); + return `[${ide}] [${sess}] [${trace}]`; +} + +function write(level, component, message, kv) { + resolveMinLevel(); + if (disabled) return; + const num = LEVELS[level] ?? LEVELS.info; + if (num < minLevel) return; + + const ts = new Date().toISOString(); + const lvl = fitCol(level.toUpperCase(), COL_LEVEL); + const comp = fitCol(`[${component}]`, COL_COMPONENT); + + const allKv = { + trace: TRACE_ID, + ide: CONTEXT.ide, + sessionId: CONTEXT.sessionId, + ...kv, + }; + const prefix = bracketPrefixes(allKv); + + // Strip promoted tags from the kv tail so we don't print them twice. + const tailKv = { ...allKv, pid: process.pid }; + for (const k of PREFIX_TAGS) delete tailKv[k]; + delete tailKv.trace; + + // EVENT summaries (sessionStart / preToolUse) use a short header plus one + // field per indented line — much easier to scan than a long k=v tail. + const line = + level === "event" + ? `${ts} ${lvl} ${comp} ${prefix} ${message}${formatKVLines(tailKv)}\n` + : `${ts} ${lvl} ${comp} ${prefix} ${message}${formatKV(tailKv)}\n`; + + try { + ensureDir(); + appendFileSync(logFile(), line); + } catch { + // swallow — the hook must keep working + } +} + +export function createLogger(component) { + return { + debug: (msg, kv) => write("debug", component, msg, kv), + info: (msg, kv) => write("info", component, msg, kv), + warn: (msg, kv) => write("warn", component, msg, kv), + error: (msg, kv) => write("error", component, msg, kv), + event: (msg, kv) => write("event", component, msg, kv), + child: (sub) => createLogger(`${component}/${sub}`), + }; +} + +export function logFilePath() { + return logFile(); +} diff --git a/modules/core/rewrite-mcp-json.mjs b/modules/core/rewrite-mcp-json.mjs new file mode 100644 index 0000000..8adcc47 --- /dev/null +++ b/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/modules/core/run-capability.mjs b/modules/core/run-capability.mjs new file mode 100644 index 0000000..8286cbc --- /dev/null +++ b/modules/core/run-capability.mjs @@ -0,0 +1,100 @@ +// Run a single capability's sessionStart by name (argv from hook runner). +// +// Static allowlist only — no arbitrary dynamic imports. Each capability is a +// separate hooks.json entry (separate subprocess); this module does not merge +// multiple capabilities in one process. +// +// Entry path convention (dev repo and plugin copy are identical): +// {pluginRoot}/{name}/scripts/index.mjs + +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { setLogContext, createLogger } from "./logger.mjs"; + +const log = createLogger("run-capability"); + +/** modules bundle root (parent of core/ and package-resolution/). */ +const PLUGIN_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** Shipped capabilities — add a name here; folder layout must match convention. */ +const ALLOWLIST = new Set(["package-resolution"]); + +/** + * @param {string} name — capability id + * @returns {string} absolute path to index.mjs + */ +function capabilityEntryPath(name) { + return path.join(PLUGIN_ROOT, name, "scripts", "index.mjs"); +} + +/** @returns {(() => Promise) | null} */ +function loadCapabilityModule(name) { + if (!ALLOWLIST.has(name)) return null; + const href = pathToFileURL(capabilityEntryPath(name)).href; + return () => import(href); +} + +function hookDurMs(ctx) { + return typeof ctx.startedAtMs === "number" + ? Date.now() - ctx.startedAtMs + : undefined; +} + +/** + * @param {string} name — capability id from process.argv[2] + * @param {object} ctx — shared session context (ide, sessionId, workspaceRoots, …) + * @returns {Promise} markdown to inject, or "" on no-op / failure + */ +export async function runCapability(name, ctx = {}) { + const load = loadCapabilityModule(name); + if (!load) { + log.error("unknown capability", { name }); + return ""; + } + + setLogContext({ ide: ctx.ide, sessionId: ctx.sessionId }); + + try { + const mod = await load(); + const cap = mod.default; + if (!cap?.sessionStart) { + log.error("capability missing sessionStart", { name }); + return ""; + } + + const text = await cap.sessionStart(ctx); + const trimmed = text?.trim() ? text : ""; + + // EVENT (visible at default info): one summary line per invocation so a + // cache-hit / quiet routing path is distinguishable from "hook never fired". + if (trimmed) { + log.event("sessionStart injected", { + enabled: true, + capabilities: name, + mode: cap.mode, + ...(cap.meta ?? {}), + bytes: trimmed.length, + durMs: hookDurMs(ctx), + }); + } else { + log.event("sessionStart no-op", { + capabilities: name, + mode: cap.mode, + ...(cap.meta ?? {}), + durMs: hookDurMs(ctx), + }); + } + + return trimmed; + } catch (err) { + log.error("capability sessionStart failed", { + capability: name, + error: err?.message ?? String(err), + }); + return ""; + } +} diff --git a/modules/core/scaffold-fingerprint.mjs b/modules/core/scaffold-fingerprint.mjs new file mode 100644 index 0000000..7ac2388 --- /dev/null +++ b/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/modules/cursor-session-start.mjs b/modules/cursor-session-start.mjs new file mode 100644 index 0000000..c5c796c --- /dev/null +++ b/modules/cursor-session-start.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Cursor sessionStart hook runner. +// +// Usage: node cursor-session-start.mjs +// Example: node cursor-session-start.mjs package-resolution +// +// stdout: JSON with additional_context. Empty object ("{}") is a no-op. + +import process from "node:process"; + +import { runCapability } from "./core/run-capability.mjs"; +import { + ensureAgentsConfigScaffold, + agentsConfigLoadWarnings, +} from "./core/agents-config.mjs"; +import { + readStdin, + parseSessionId, + detectHarness, + parseWorkspaceRoots, +} from "./core/io.mjs"; +import { setLogContext, createLogger } from "./core/logger.mjs"; + +const HARNESS_ID = "cursor"; +const log = createLogger("session-start"); + +/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */ +function formatSessionStartStdout(text) { + if (!text?.trim()) return null; + return JSON.stringify({ additional_context: text }); +} + +function writeStdout(payload) { + if (payload !== null) process.stdout.write(payload); +} + +function writeNoOp() { + process.stdout.write("{}"); +} + +async function main() { + const capability = process.argv[2]; + if (!capability) { + writeNoOp(); + return; + } + + const startedAtMs = Date.now(); + const stdinRaw = await readStdin(); + const harness = detectHarness(stdinRaw); + if (harness && harness !== HARNESS_ID) { + setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); + log.warn("harness mismatch; wrong adapter invoked", { + expected: HARNESS_ID, + detected: harness, + adapter: "cursor-session-start", + }); + writeNoOp(); + return; + } + const sessionId = parseSessionId(stdinRaw); + const workspaceRoots = parseWorkspaceRoots(stdinRaw); + setLogContext({ ide: HARNESS_ID, sessionId }); + ensureAgentsConfigScaffold(); + for (const w of agentsConfigLoadWarnings()) { + log.warn(w.message, { path: w.path }); + } + const text = await runCapability(capability, { + ide: HARNESS_ID, + sessionId, + workspaceRoots, + startedAtMs, + }); + writeStdout(formatSessionStartStdout(text)); +} + +main().catch(() => { + writeNoOp(); + process.exit(0); +}); diff --git a/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md b/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md new file mode 100644 index 0000000..4467ecb --- /dev/null +++ b/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/modules/package-resolution/onboarding/session-start-nudge.md b/modules/package-resolution/onboarding/session-start-nudge.md new file mode 100644 index 0000000..91335ee --- /dev/null +++ b/modules/package-resolution/onboarding/session-start-nudge.md @@ -0,0 +1,24 @@ +# 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}}). + +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 matching **T** above) + +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/modules/package-resolution/scripts/apr-heartbeat.mjs b/modules/package-resolution/scripts/apr-heartbeat.mjs new file mode 100644 index 0000000..096f58f --- /dev/null +++ b/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/modules/package-resolution/scripts/configure.mjs b/modules/package-resolution/scripts/configure.mjs new file mode 100644 index 0000000..9979cee --- /dev/null +++ b/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/modules/package-resolution/scripts/eager-setup-receipt.mjs b/modules/package-resolution/scripts/eager-setup-receipt.mjs new file mode 100644 index 0000000..8f3c00a --- /dev/null +++ b/modules/package-resolution/scripts/eager-setup-receipt.mjs @@ -0,0 +1,233 @@ +// Eager-setup receipt — durable "already configured via `jf setup`" ledger. +// +// `jf setup` mutates USER-GLOBAL package-manager config (`~/.npmrc`, +// `~/.docker/config.json`, …), not per-workspace state, so the skip decision +// keys on `serverId + packageManager` (NOT workspace, NOT Artifactory package +// type). One governed type can own several package managers (pypi → +// pip/pipenv/uv); each gets its own receipt entry so status stays honest +// (Option C). +// +// Schema 2 = package-manager-keyed entries only. Stored in a dedicated file +// (`package-setup-v2.json`) so older plugin builds that still write schema-1 +// `package-setup.json` cannot downgrade or thrash this ledger. On first run +// after upgrade the v2 file is empty — idempotent `jf setup` re-fills it once. +// +// This is separate from the resolver cache (different key granularity + +// invalidation; the resolver's normalizer would strip these co-located fields). +// +// File: ~/.jfrog/skills-cache/package-setup-v2.json +// { +// "schemaVersion": 2, +// "servers": { +// "": { +// "url": "https://corp.jfrog.io", +// "pip": { "repoKey": "pypi-virtual", "status": "ok", "configuredAt": "..." }, +// "uv": { "repoKey": "pypi-virtual", "status": "ok", "configuredAt": "..." } +// } +// } +// } + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { createLogger } from "../../core/logger.mjs"; + +const log = createLogger("eager-setup-receipt"); + +const RECEIPT_SCHEMA_VERSION = 2; + +// Reserved key inside a server entry (everything else is a package-manager receipt). +const RESERVED_KEYS = new Set(["url"]); + +/** @returns {string} `~/.jfrog/skills-cache` */ +function cacheDir() { + return path.join(homedir(), ".jfrog", "skills-cache"); +} + +/** Schema-2 receipt path (not shared with legacy schema-1 `package-setup.json`). */ +export function receiptFilePath() { + return path.join(cacheDir(), "package-setup-v2.json"); +} + +/** @returns {string} absolute path to the schema-2 receipt file */ +function receiptFile() { + return receiptFilePath(); +} + +/** @returns {{ schemaVersion: number, servers: Record }} */ +function emptyReceipt() { + return { schemaVersion: RECEIPT_SCHEMA_VERSION, servers: {} }; +} + +/** + * Normalize one package-manager receipt entry, or null if invalid. + * @param {unknown} entry + * @returns {{ repoKey: string, status: string, configuredAt: string|null, reason?: string } | null} + */ +function normalizeTypeEntry(entry) { + if (!entry || typeof entry !== "object") return null; + if (typeof entry.repoKey !== "string" || !entry.repoKey) return null; + const status = + entry.status === "ok" || entry.status === "failed" + ? entry.status + : "failed"; + return { + repoKey: entry.repoKey, + status, + configuredAt: + typeof entry.configuredAt === "string" ? entry.configuredAt : null, + ...(entry.reason ? { reason: String(entry.reason).slice(0, 500) } : {}), + }; +} + +/** Normalize raw on-disk JSON to `{ schemaVersion, servers }`; drop junk. */ +export function normalizeReceipt(data) { + if ( + !data || + typeof data !== "object" || + data.schemaVersion !== RECEIPT_SCHEMA_VERSION + ) { + if (data && typeof data === "object" && data.schemaVersion != null) { + log.warn("eager-setup receipt ignored: unexpected schemaVersion", { + schemaVersion: data.schemaVersion, + expected: RECEIPT_SCHEMA_VERSION, + file: path.basename(receiptFilePath()), + }); + } + return emptyReceipt(); + } + const servers = {}; + if (data.servers && typeof data.servers === "object") { + for (const [serverId, raw] of Object.entries(data.servers)) { + if (!raw || typeof raw !== "object") continue; + const entry = {}; + if (typeof raw.url === "string" && raw.url) entry.url = raw.url; + for (const [key, val] of Object.entries(raw)) { + if (RESERVED_KEYS.has(key)) continue; + const norm = normalizeTypeEntry(val); + if (norm) entry[key] = norm; + } + servers[serverId] = entry; + } + } + return { schemaVersion: RECEIPT_SCHEMA_VERSION, servers }; +} + +/** + * Read and normalize the schema-2 eager-setup receipt from disk. + * @returns {Promise<{ schemaVersion: number, servers: Record }>} + */ +export async function readReceipt() { + try { + const raw = await readFile(receiptFile(), "utf8"); + return normalizeReceipt(JSON.parse(raw)); + } catch { + return emptyReceipt(); + } +} + +/** + * Persist the in-memory receipt root to `package-setup-v2.json`. + * @param {{ servers?: Record }} root + * @returns {Promise} + */ +export async function writeReceipt(root) { + const file = receiptFile(); + await mkdir(cacheDir(), { recursive: true }); + await writeFile( + file, + JSON.stringify( + { schemaVersion: RECEIPT_SCHEMA_VERSION, servers: root.servers ?? {} }, + null, + 2, + ), + ); +} + +/** + * Whether `configuredAt` is still within `ttlDays`. + * `ttlDays === 0` means always re-check (never trust time-based state). + * @param {string|null} configuredAt + * @param {number} ttlDays + * @returns {boolean} + */ +function receiptWithinTtl(configuredAt, ttlDays) { + if (!configuredAt) return false; + if (ttlDays === 0) return false; + if (typeof ttlDays !== "number" || !Number.isFinite(ttlDays) || ttlDays < 0) + return false; + const ttlMs = ttlDays * 24 * 60 * 60 * 1000; + const age = Date.now() - new Date(configuredAt).getTime(); + return age >= 0 && age < ttlMs; +} + +/** + * Decide whether `jf setup` can be SKIPPED for one (serverId, packageManager). + * + * A recorded result — success OR failure — is trusted for `ttlDays` (the unified + * `cacheTtlDays`). So a persistent failure is retried at most once per TTL + * window instead of every session, but a fixed repoKey/server retries at once. + * + * @returns {{ skip: boolean, reason: string }} + * reasons that RUN: no-receipt | server-url-changed | no-entry | + * repokey-changed | ttl-expired | failed-retry + * reasons that SKIP: receipt-hit (ok) | failed-deferred (failed, still fresh) + */ +export function evaluateSetupNeed( + receipt, + { serverId, url, packageManager, ttlDays, repoKey }, +) { + const server = receipt?.servers?.[serverId]; + if (!server) return { skip: false, reason: "no-receipt" }; + if (url && server.url && server.url !== url) + return { skip: false, reason: "server-url-changed" }; + const entry = server[packageManager]; + if (!entry) return { skip: false, reason: "no-entry" }; + // A changed repo key means the admin/workspace fixed the target — retry now, + // whether the previous result was ok or failed. + if (entry.repoKey !== repoKey) + return { skip: false, reason: "repokey-changed" }; + if (!receiptWithinTtl(entry.configuredAt, ttlDays)) + return { + skip: false, + reason: entry.status === "ok" ? "ttl-expired" : "failed-retry", + }; + // Fresh + unchanged: skip. Surface failures separately so the caller can tell + // "already configured" from "still failing, deferred until the TTL elapses". + if (entry.status !== "ok") return { skip: true, reason: "failed-deferred" }; + return { skip: true, reason: "receipt-hit" }; +} + +/** Read the recorded entry for (serverId, packageManager), or null. */ +export function receiptEntry(receipt, serverId, packageManager) { + return receipt?.servers?.[serverId]?.[packageManager] ?? null; +} + +/** + * Merge a single setup result into the receipt object (in place) and return it. + * Only status "ok" marks a success; failures are recorded (not as ok) so the + * next session can surface + retry them. Keyed by `jf setup` package-manager token. + */ +export function applySetupResult( + root, + { serverId, url, packageManager, repoKey, status, reason }, +) { + if (!root.servers) root.servers = {}; + const server = root.servers[serverId] ?? {}; + if (url) server.url = url; + server[packageManager] = { + repoKey, + status: status === "ok" ? "ok" : "failed", + configuredAt: new Date().toISOString(), + ...(reason ? { reason: String(reason).slice(0, 500) } : {}), + }; + root.servers[serverId] = server; + log.debug("receipt entry staged", { + serverId, + packageManager, + repoKey, + status, + }); + return root; +} diff --git a/modules/package-resolution/scripts/eager-setup.mjs b/modules/package-resolution/scripts/eager-setup.mjs new file mode 100644 index 0000000..40874c1 --- /dev/null +++ b/modules/package-resolution/scripts/eager-setup.mjs @@ -0,0 +1,852 @@ +// Eager `jf setup` — "auto setup on startup". +// +// Two roles in one file: +// 1. ORCHESTRATOR (foreground, imported by index.mjs): after resolution, +// figure out which governed + `autoSetup` + resolved types still +// 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 +// record each result. `jf setup` mutates USER-GLOBAL package-manager +// config, so this is serialized across sessions. +// +// `jf setup` validates the repo itself (`GET /api/repositories/` + non-zero +// exit on bad repo / missing permission), so it is the authoritative check — no +// separate pre-setup GET here, and eligibility does NOT require `verifyRepos`. + +import { spawn, spawnSync } from "node:child_process"; +import { + openSync, + closeSync, + writeSync, + readFileSync, + unlinkSync, + existsSync, + mkdirSync, +} from "node:fs"; +import { homedir, hostname } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { createLogger } from "../../core/logger.mjs"; +import { + loadAgentsConfig, + isAutoSetup, + globalDeclaredTypes, +} from "../../core/agents-config.mjs"; +import { getPlatformIdentity } from "../../core/jf-identity.mjs"; +import { + prepareSessionResolve, + resolve as resolveRepo, + governedPackageTypes, +} from "./resolver.mjs"; +import { + readReceipt, + writeReceipt, + receiptEntry, + evaluateSetupNeed, + applySetupResult, +} from "./eager-setup-receipt.mjs"; +import { + TYPE_TO_PACKAGE_MANAGERS, + packageManagersForType, + 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"); + +/** Ceiling for Option C fan-out — used when lock metadata lacks `jobCount`. */ +const MAX_PACKAGE_MANAGER_JOBS = Object.values(TYPE_TO_PACKAGE_MANAGERS).reduce( + (n, family) => n + family.length, + 0, +); + +/** 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 ` + + "admin-declared in defaultGlobalRepos (~/.jfrog/agents-conf.json). " + + "Workspace-only types are never autoSetup-eligible." + ); +} + +/** Per-package-manager `jf setup` spawn timeout (ms). */ +const PER_PACKAGE_MANAGER_TIMEOUT_MS = 60_000; + +/** @returns {string} `~/.jfrog/skills-cache` */ +function cacheDir() { + return path.join(homedir(), ".jfrog", "skills-cache"); +} + +/** @returns {string} path to the global eager-setup lock file */ +function lockFile() { + return path.join(cacheDir(), "package-setup.lock"); +} + +/** @returns {string} absolute path to this module (worker entry) */ +function workerPath() { + return fileURLToPath(new URL("./eager-setup.mjs", import.meta.url)); +} + +// --------------------------------------------------------------------------- +// Orchestrator (foreground) +// --------------------------------------------------------------------------- + +/** + * 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 + * @param {Record} resolvedByType + * @returns {{type:string, repoKey:string, packageManager:string}[]} + */ +export function computeEligibleJobs(governed, resolvedByType) { + 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) { + log.debug("eager skip: auto-setup but unresolved", { type }); + continue; + } + const packageManagers = packageManagersForType(type); + if (!packageManagers.length) { + log.warn("eager skip: no jf package-manager family mapping", { type }); + continue; + } + for (const packageManager of packageManagers) { + jobs.push({ type, repoKey: r.repoKey, packageManager }); + } + } + // 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 (!adminSet.has(type)) { + log.warn(`eager setup skipped: ${ungovernedAutoSetupHint(type)}`, { + type, + }); + } + } + } + return jobs; +} + +/** + * Build the injected zero-touch status note (package-manager names, not Artifactory types). + * @param {{ + * configured: string[], + * pending: string[], + * deferred: string[], + * skippedMissing?: string[], + * skippedConflict?: string[], + * skippedUnsupported?: string[], + * skippedUnparsed?: string[], + * setupBusy?: boolean, + * }} parts + * @returns {string} markdown note or "" + */ +function statusNote({ + configured, + pending, + deferred, + skippedMissing, + skippedConflict, + skippedUnsupported, + skippedUnparsed, + setupBusy, +}) { + const parts = []; + if (setupBusy && pending.length) { + parts.push( + `waiting to set up (another setup is already running; will try again next session): ${pending.join(", ")}`, + ); + } else if (pending.length) { + parts.push(`setting up in the background: ${pending.join(", ")}`); + } + if (configured.length) { + parts.push(`already set up: ${configured.join(", ")}`); + } + if (deferred.length) { + parts.push( + `could not set up last time (will try again later): ${deferred.join(", ")}`, + ); + } + if (skippedMissing?.length) { + parts.push( + `skipped (not installed on this machine): ${skippedMissing.join(", ")}`, + ); + } + if (skippedConflict?.length) { + parts.push( + `left unchanged (already using another JFrog / registry): ` + + `${skippedConflict.join(", ")}. Ask the user: "Switch to this JFrog ` + + `instance?" If they say yes, run \`jf setup \` ` + + `(with \`--server-id\` / \`--repo\` as needed) only for each approved ` + + `package manager — not bare \`jf setup\``, + ); + } + if (skippedUnsupported?.length) { + parts.push( + `skipped (update the JFrog CLI to enable setup for): ` + + `${skippedUnsupported.join(", ")}`, + ); + } + if (skippedUnparsed?.length) { + parts.push( + `skipped (could not check JFrog CLI setup support for): ` + + `${skippedUnparsed.join(", ")} — try updating the JFrog CLI`, + ); + } + if (!parts.length) return ""; + return `> **Package manager setup** — ${parts.join("; ")}.`; +} + +/** + * Sync-mode `spawnSync` timeout for the eager-setup worker. + * Scales with job count so Option C multi-package-manager runs are not killed mid-way. + * @param {number} jobCount number of `jf setup` jobs in the payload + * @returns {number} timeout in milliseconds + */ +export function syncWorkerTimeoutMs(jobCount) { + return Math.max( + 120_000, + PER_PACKAGE_MANAGER_TIMEOUT_MS * Math.max(jobCount, 1) + 30_000, + ); +} + +/** + * Spawn the background eager-setup worker (detached) or run it synchronously + * when `JFROG_EAGER_SETUP_SYNC=1`. + * @param {string} payloadB64 base64 JSON `{ serverId, url, jobs }` + * @param {number} [jobCount=1] used to size the sync-mode timeout + * @returns {void} + */ +function spawnWorker(payloadB64, jobCount = 1) { + // Synchronous mode: deterministic tests + a bounded fallback where detached + // survival is unreliable. Otherwise spawn detached and unref so the child + // outlives the hook process (runtime is irrelevant to the 7s budget). + if (process.env.JFROG_EAGER_SETUP_SYNC === "1") { + spawnSync(process.execPath, [workerPath(), "--run", payloadB64], { + stdio: "ignore", + env: process.env, + timeout: syncWorkerTimeoutMs(jobCount), + }); + return; + } + try { + const child = spawn(process.execPath, [workerPath(), "--run", payloadB64], { + detached: true, + stdio: "ignore", + env: process.env, + }); + child.unref(); + } catch (err) { + log.warn("failed to spawn eager-setup worker", { + error: err?.message ?? String(err), + }); + } +} + +/** + * Foreground entry called from sessionStart (routing mode only). Decides which + * governed+auto-setup+resolved types need `jf setup`, spawns the background worker + * if any do, and returns a status note for the injected instruction ("" if + * nothing to say). Never throws — eager setup must never break injection. + * @param {{ workspaceRoots?: string[] }} ctx + * @returns {Promise} + */ +export async function orchestrateEagerSetup(ctx = {}) { + try { + const identity = getPlatformIdentity().identity; + if (!identity) return ""; + + await prepareSessionResolve({ workspaceRoots: ctx.workspaceRoots }); + const governed = governedPackageTypes(); + const resolvedByType = {}; + for (const type of governed) { + const r = await resolveRepo(type); + if (r) resolvedByType[type] = r; + } + + const jobs = computeEligibleJobs(governed, resolvedByType); + if (!jobs.length) return ""; + + const { cacheTtlDays } = loadAgentsConfig().packageResolution; + const receipt = await readReceipt(); + const serverId = identity.serverId ?? "default"; + const url = identity.url; + + // Intersect the type→package-manager ceiling with what the *installed* + // `jf setup` supports, so an outdated CLI (e.g. one without `jf setup uv`) + // surfaces an actionable "update the JFrog CLI" note instead of silently + // sitting in the background worker's skip log. + const supported = supportedPackageManagers(); + + const configured = []; + const pending = []; + const deferred = []; + const skippedMissing = []; + const skippedConflict = []; + const skippedUnsupported = []; + const skippedUnparsed = []; + const toRun = []; + for (const job of jobs) { + // Binary probe in the orchestrator so the injected note can list skips + // before the detached worker runs (PATH walk — no spawn). Worker re-checks. + if (!packageManagerBinaryOnPath(job.packageManager)) { + skippedMissing.push(job.packageManager); + log.warn("eager skip: package manager binary not on PATH", { + type: job.type, + packageManager: job.packageManager, + }); + continue; + } + // Fail-closed: an unparseable `jf setup --help` means we cannot confirm + // support, so skip rather than bypass the filter and risk running an + // unsupported `jf setup `. + if (supported === null) { + skippedUnparsed.push(job.packageManager); + log.warn( + "eager skip: could not parse `jf setup --help` output — failing closed", + { type: job.type, packageManager: job.packageManager }, + ); + continue; + } + if (!supported.has(job.packageManager)) { + skippedUnsupported.push(job.packageManager); + log.warn( + "eager skip: package manager unsupported by installed jf setup", + { + type: job.type, + packageManager: job.packageManager, + hint: "update the JFrog CLI to the latest version", + }, + ); + continue; + } + const conflict = detectSetupConflict(job.packageManager, url); + if (conflict.conflict) { + const hostHint = + conflict.existingHost && conflict.targetHost + ? ` (${conflict.existingHost} → ${conflict.targetHost})` + : ""; + skippedConflict.push(`${job.packageManager}${hostHint}`); + log.warn( + "eager skip: existing package-manager config points elsewhere", + { + type: job.type, + packageManager: job.packageManager, + existingHost: conflict.existingHost, + targetHost: conflict.targetHost, + }, + ); + continue; + } + const need = evaluateSetupNeed(receipt, { + serverId, + url, + packageManager: job.packageManager, + repoKey: job.repoKey, + ttlDays: cacheTtlDays, + }); + if (need.skip) { + // "failed-deferred" = a still-failing entry within its TTL: don't retry + // this session (no jf setup, no WARN), but surface it in the note. + if (need.reason === "failed-deferred") + deferred.push(job.packageManager); + else configured.push(job.packageManager); + continue; + } + pending.push(job.packageManager); + toRun.push(job); + log.debug("eager setup needed", { + type: job.type, + packageManager: job.packageManager, + repoKey: job.repoKey, + reason: need.reason, + }); + } + + let setupBusy = false; + if (toRun.length) { + if (isLiveLockHeld()) { + setupBusy = true; + log.warn( + "eager-setup deferred: another jf setup worker holds the lock", + { pendingJobCount: toRun.length }, + ); + } else { + const payload = Buffer.from( + JSON.stringify({ serverId, url, jobs: toRun }), + "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); + } + } + } + } + } + + return statusNote({ + configured, + pending, + deferred, + skippedMissing, + skippedConflict, + skippedUnsupported, + skippedUnparsed, + setupBusy, + }); + } catch (err) { + log.warn("orchestrateEagerSetup failed", { + error: err?.message ?? String(err), + }); + return ""; + } +} + +// --------------------------------------------------------------------------- +// Lock (worker-only, best-effort, one global lock) +// --------------------------------------------------------------------------- + +/** + * Max age before an eager-setup lock is treated as stale and reclaimable. + * Scales with the owner's job count (+30s buffer, same as {@link syncWorkerTimeoutMs}). + * Floor at 120s. + * @param {number} ownerJobCount owner job count (from lock metadata) + * @returns {number} milliseconds + */ +export function staleThresholdMs(ownerJobCount) { + return Math.max( + PER_PACKAGE_MANAGER_TIMEOUT_MS * Math.max(ownerJobCount, 1) + 30_000, + 120_000, + ); +} + +/** + * Job count that governs staleness for an existing lock — the **owner's** count + * from lock metadata, not the contender's. Missing/invalid → conservative max + * so a long Option C run cannot be reclaimed early by a 1-job contender. + * @param {{ jobCount?: unknown } | null} meta + * @returns {number} + */ +export function lockOwnerJobCount(meta) { + const n = meta?.jobCount; + if (typeof n === "number" && Number.isFinite(n) && n >= 1) { + return Math.min(Math.floor(n), MAX_PACKAGE_MANAGER_JOBS); + } + return MAX_PACKAGE_MANAGER_JOBS; +} + +/** + * @param {number} pid + * @returns {boolean} true if the process appears to exist + */ +function pidAlive(pid) { + if (!pid || typeof pid !== "number") return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return err?.code === "EPERM"; // exists but not ours + } +} + +/** + * @returns {{ pid?: number, hostname?: string, serverId?: string, jobCount?: number, startedAt?: string } | null} + */ +function readLock() { + try { + return JSON.parse(readFileSync(lockFile(), "utf8")); + } catch { + return null; + } +} + +/** + * Whether a lock may be reclaimed. Uses the lock owner's jobCount + * (see {@link lockOwnerJobCount}), not the contender's. + * @param {{ pid?: number, hostname?: string, jobCount?: number, startedAt?: string } | null} meta + * @returns {boolean} + */ +function isStaleLock(meta) { + if (!meta) return true; + const ageMs = Date.now() - new Date(meta.startedAt ?? 0).getTime(); + // Non-finite age (invalid/missing startedAt → NaN) is reclaimable — same as + // epoch/missing. Otherwise a corrupt startedAt would never age-stale. + if ( + !Number.isFinite(ageMs) || + ageMs >= staleThresholdMs(lockOwnerJobCount(meta)) + ) { + return true; + } + if (meta.hostname === hostname() && !pidAlive(meta.pid)) return true; + return false; +} + +/** + * Atomically create the lock file (O_CREAT|O_EXCL). Throws if held (EEXIST). + * @param {string} serverId + * @param {number} jobCount persisted for contenders' stale checks + */ +function tryWriteLock(serverId, jobCount) { + const fd = openSync(lockFile(), "wx"); + try { + const meta = { + pid: process.pid, + hostname: hostname(), + serverId, + jobCount: Math.max(jobCount, 1), + startedAt: new Date().toISOString(), + }; + writeSync(fd, JSON.stringify(meta)); + } finally { + closeSync(fd); + } +} + +/** + * Acquire the global lock. Returns true on success. On live contention → false + * (skip, don't wait). On a stale lock → reclaim + retry once. + * @param {string} serverId + * @param {number} jobCount this worker's job count (persisted for contenders) + */ +function acquireLock(serverId, jobCount) { + mkdirSync(cacheDir(), { recursive: true }); + try { + tryWriteLock(serverId, jobCount); + log.debug("lock acquired", { pid: process.pid, jobCount }); + return true; + } catch (err) { + if (err?.code !== "EEXIST") { + log.warn("lock open failed", { error: err?.message ?? String(err) }); + return false; + } + } + const existing = readLock(); + if (!isStaleLock(existing)) { + log.warn("eager-setup skipped: another jf setup worker holds the lock", { + owner: existing?.pid, + ownerJobCount: lockOwnerJobCount(existing), + startedAt: existing?.startedAt, + hostname: existing?.hostname, + }); + return false; + } + log.debug("reclaiming stale lock", { + owner: existing?.pid, + startedAt: existing?.startedAt, + }); + try { + unlinkSync(lockFile()); + } catch { + // someone else may have removed it — fall through to re-acquire + } + try { + tryWriteLock(serverId, jobCount); + log.debug("lock acquired after reclaim", { pid: process.pid, jobCount }); + return true; + } catch { + log.warn("eager-setup skipped: lost race to re-acquire lock"); + return false; + } +} + +/** + * True when a non-stale eager-setup lock file is held (best-effort probe). + * @returns {boolean} + */ +function isLiveLockHeld() { + try { + if (!existsSync(lockFile())) return false; + return !isStaleLock(readLock()); + } catch { + return false; + } +} + +/** + * Best-effort unlock after the worker finishes (or fails). + * Only unlinks when this process still owns the lock — a contender may have + * reclaimed an age-stale lock while we were still running; deleting theirs + * would drop mutual exclusion. + * Exported for unit tests of the ownership guard. + */ +export function releaseLock() { + try { + const meta = readLock(); + if (!meta) return; + if (meta.pid !== process.pid || meta.hostname !== hostname()) { + log.debug("lock not owned; skip release", { + pid: process.pid, + owner: meta.pid, + ownerHostname: meta.hostname, + }); + return; + } + unlinkSync(lockFile()); + log.debug("lock released", { pid: process.pid }); + } catch { + // best-effort + } +} + +// --------------------------------------------------------------------------- +// Worker (background) +// --------------------------------------------------------------------------- + +/** + * Parse the `Supported package managers are: a, b, c.` line from `jf setup --help`. + * Real `jf` ends the list with a period, so the capture stops at `.`/newline — + * otherwise the last token keeps a trailing dot (e.g. `uv.`) and never matches. + * @returns {Set|null} lowercase tokens, or null if help could not be parsed + */ +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); + if (!m) return null; + return new Set( + m[1] + .split(/[,\s]+/) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + ); + } catch { + return null; + } +} + +/** + * Distill `jf setup` output into a concise cause. Keeps `[Error]`/`[Fatal]` + * lines (prefix stripped), else the trimmed tail. + * @param {string|null|undefined} stdout + * @param {string|null|undefined} stderr + * @returns {string} + */ +function extractJfError(stdout, stderr) { + const raw = `${stdout ?? ""}\n${stderr ?? ""}`; + const lines = raw + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + const stripPrefix = (l) => + l.replace(/^\d{1,2}:\d{2}:\d{2}\s+\[(?:Error|Fatal)\]\s*/i, "").trim(); + const errors = lines + .filter((l) => /\[(?:Error|Fatal)\]/i.test(l)) + .map(stripPrefix) + .filter(Boolean); + const detail = errors.length ? errors.join("; ") : (lines.at(-1) ?? ""); + return detail.slice(0, 300); +} + +/** + * Run `jf setup --server-id --repo` with a per-PM timeout. + * @param {string} packageManager + * @param {string} serverId + * @param {string} repoKey + * @returns {{ ok: true } | { ok: false, reason: string }} + */ +function runJfSetup(packageManager, serverId, repoKey) { + const args = [ + "setup", + packageManager, + "--server-id", + serverId, + "--repo", + 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}` }; + } + if (res.status !== 0) { + return { + ok: false, + reason: `exit ${res.status}: ${extractJfError(res.stdout, res.stderr)}`, + }; + } + return { ok: true }; +} + +/** + * Background worker body. Acquire lock → re-check receipt → `jf setup` per job → + * record results → release lock. Best-effort; never throws to the caller. + * @param {{ serverId:string, url:string, jobs:{type:string,repoKey:string,packageManager:string}[] }} payload + */ +export async function runWorker(payload) { + const { serverId, url, jobs } = payload; + if (!Array.isArray(jobs) || !jobs.length) return; + + if (!acquireLock(serverId, jobs.length)) return; + try { + const { cacheTtlDays } = loadAgentsConfig().packageResolution; + // Re-read the receipt UNDER the lock — another worker may have finished + // between the foreground spawn and this acquire. + const root = await readReceipt(); + const supported = supportedPackageManagers(); + + for (const job of jobs) { + if (!packageManagerBinaryOnPath(job.packageManager)) { + log.warn("worker skip: package manager binary not on PATH", { + type: job.type, + packageManager: job.packageManager, + }); + continue; + } + const need = evaluateSetupNeed(root, { + serverId, + url, + packageManager: job.packageManager, + repoKey: job.repoKey, + ttlDays: cacheTtlDays, + }); + if (need.skip) { + log.debug("worker skip: receipt fresh under lock", { + packageManager: job.packageManager, + reason: need.reason, + }); + continue; + } + // Fail-closed: an unparseable `jf setup --help` means we cannot confirm + // support, so skip rather than bypass the filter (mirrors orchestrator). + if (supported === null) { + log.warn( + "worker skip: could not parse `jf setup --help` output — failing closed", + { type: job.type, packageManager: job.packageManager }, + ); + continue; + } + if (!supported.has(job.packageManager)) { + log.warn("worker skip: package manager unsupported by jf setup", { + type: job.type, + packageManager: job.packageManager, + }); + continue; + } + + // Re-check for a foreign registry conflict under the lock — mirrors the + // orchestrator's check, closing the race where a developer runs a + // manual `npm config set registry` between the foreground spawn and + // this worker acquiring the lock. + const conflict = detectSetupConflict(job.packageManager, url); + if (conflict.conflict) { + log.warn( + "worker skip: existing package-manager config points elsewhere", + { + type: job.type, + packageManager: job.packageManager, + existingHost: conflict.existingHost, + targetHost: conflict.targetHost, + }, + ); + continue; + } + + const result = runJfSetup(job.packageManager, serverId, job.repoKey); + if (result.ok) { + applySetupResult(root, { + serverId, + url, + packageManager: job.packageManager, + repoKey: job.repoKey, + status: "ok", + }); + log.info("jf setup", { + serverId, + type: job.type, + packageManager: job.packageManager, + repoKey: job.repoKey, + status: "ok", + }); + } else { + applySetupResult(root, { + serverId, + url, + packageManager: job.packageManager, + repoKey: job.repoKey, + status: "failed", + reason: result.reason, + }); + log.warn("jf setup", { + serverId, + type: job.type, + packageManager: job.packageManager, + repoKey: job.repoKey, + status: "failed", + reason: result.reason, + }); + } + // Persist progress after each package manager so a crash mid-run keeps prior results. + await writeReceipt(root); + } + } finally { + releaseLock(); + } +} + +// --------------------------------------------------------------------------- +// CLI entry (worker mode) +// --------------------------------------------------------------------------- + +const isMain = import.meta.url === `file://${process.argv[1]}`; +if (isMain && process.argv[2] === "--run") { + const b64 = process.argv[3]; + try { + const payload = JSON.parse(Buffer.from(b64, "base64").toString("utf8")); + await runWorker(payload); + } catch (err) { + log.warn("worker failed to parse/run payload", { + error: err?.message ?? String(err), + }); + } +} diff --git a/modules/package-resolution/scripts/feature-flag.mjs b/modules/package-resolution/scripts/feature-flag.mjs new file mode 100644 index 0000000..aa48a3d --- /dev/null +++ b/modules/package-resolution/scripts/feature-flag.mjs @@ -0,0 +1,91 @@ +// Feature-flag check — decides the operating `mode` for the session-policy +// hook (instruction injection). +// +// Resolution order (first match wins): +// +// 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 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 | insecure-url +// (jf-unreachable stays routing best-effort — not a pending cause) +// +// Modes: +// "off" — do nothing (no injection). +// "routing" — inject resolved Artifactory URLs + routing policy. +// "pending" — identity missing/unusable/rejected: inject the advisory +// "routing not ready" notice (no resolved URLs). Advisory +// steering only — real enforcement is durable PM config +// (jf setup) + server-side Curation. +// +// Repo keys come from agents-conf.json defaultGlobalRepos (resolver.mjs). + +import process from "node:process"; + +import { createLogger } from "../../core/logger.mjs"; +import { getAgentsConfigSection } from "../../core/agents-config.mjs"; +import { + getReadyPlatformIdentity, + identityLabel, + IdentityCause, +} from "../../core/jf-identity.mjs"; + +const log = createLogger("feature-flag"); + +function isEnvDisabled() { + return process.env.JF_AGENT_PACKAGE_RESOLUTION_DISABLE === "1"; +} + +function isEnabledInConfig() { + const pr = getAgentsConfigSection("packageResolution"); + return pr?.enabled === true; +} + +export async function isPackageResolutionEnabled() { + if (isEnvDisabled()) { + log.debug("off", { reason: "DISABLE" }); + return { + mode: "off", + reason: "DISABLE", + identity: "none", + cause: IdentityCause.OK, + }; + } + + if (!isEnabledInConfig()) { + log.debug("off", { reason: "NOT_ENABLED" }); + return { + mode: "off", + reason: "NOT_ENABLED", + identity: "none", + cause: IdentityCause.OK, + }; + } + + // Probe credentials so expired/revoked tokens fail closed to pending + // instead of "routing" with every row unresolved. + const { identity, cause } = await getReadyPlatformIdentity(); + if (!identity) { + log.debug("pending", { reason: "missing-identity", cause }); + return { + mode: "pending", + reason: "missing-identity", + identity: "none", + cause, + }; + } + + log.debug("routing", { + reason: "jf-config", + identity: identityLabel(identity), + }); + return { + mode: "routing", + reason: "jf-config", + identity: identityLabel(identity), + cause: IdentityCause.OK, + }; +} diff --git a/modules/package-resolution/scripts/index.mjs b/modules/package-resolution/scripts/index.mjs new file mode 100644 index 0000000..22c4e17 --- /dev/null +++ b/modules/package-resolution/scripts/index.mjs @@ -0,0 +1,139 @@ +// package-resolution capability — harness-agnostic entrypoint. +// +// 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 { axesForSessionStart } from "../../core/jf-user-agent.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 tokens. Unknown ide → omit, not `unknown`. + * Direct sessionStart (no ide) infers from strong env only. + * Clears leftover JFROG_APR_UA_* when an axis is absent (Claude omit client). + */ +export function stampHookUaAxes(ctx = {}) { + const axes = axesForSessionStart(ctx, process.env); + if (axes.tool) process.env.JFROG_APR_UA_TOOL = axes.tool; + else delete process.env.JFROG_APR_UA_TOOL; + if (axes.client) process.env.JFROG_APR_UA_CLIENT = axes.client; + else delete process.env.JFROG_APR_UA_CLIENT; +} + +export const packageResolution = { + name: "package-resolution", + + // Last resolved feature-flag mode ("off"|"pending"|"routing") and render detail + // for the dispatcher EVENT log line. + mode: undefined, + meta: undefined, + + /** @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 from adapter id, or print-policy inference when ide is absent. + stampHookUaAxes(ctx); + + const killSwitch = flag.mode === "off" && flag.reason === "DISABLE"; + + // Off: only the nudge (if eligible) is injected, no routing/pending policy. + // No routing resolution happens in this mode, so there is no "governed + // type unresolved" state to check the nudge against. + if (flag.mode === "off") { + 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), + }); + } + 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 + // blocks/breaks injection. + 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, + }); + + // Nudge is decided AFTER routing resolves, not before: its "public + // registries are the default, say No to decline" framing must never sit + // beside a hard block on a governed-but-unresolved type — that pairing is + // exactly what let an agent read a blocked type as merely unconfigured. + const blockedByUnresolved = Boolean( + meta.unresolved && meta.unresolved !== "-", + ); + let nudge = { offer: false, reason: "nudge-error", text: "" }; + if (blockedByUnresolved) { + nudge = { offer: false, reason: "governed-type-unresolved", text: "" }; + } else { + try { + nudge = resolveOnboardingNudge({ ide: ctx.ide, killSwitch }); + } catch (err) { + log.warn("onboarding nudge failed", { + error: err?.message ?? String(err), + }); + } + } + + 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 combined; + }, +}; + +export default packageResolution; diff --git a/modules/package-resolution/scripts/onboarding-decline-cache.mjs b/modules/package-resolution/scripts/onboarding-decline-cache.mjs new file mode 100644 index 0000000..5d97992 --- /dev/null +++ b/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/modules/package-resolution/scripts/onboarding.mjs b/modules/package-resolution/scripts/onboarding.mjs new file mode 100644 index 0000000..6f8b6bf --- /dev/null +++ b/modules/package-resolution/scripts/onboarding.mjs @@ -0,0 +1,251 @@ +// 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"; +export const CODEX_ADMIN_GUIDE_URL = + "https://github.com/jfrog/codex-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, + codex: CODEX_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/modules/package-resolution/scripts/package-manager-family.mjs b/modules/package-resolution/scripts/package-manager-family.mjs new file mode 100644 index 0000000..318f9fb --- /dev/null +++ b/modules/package-resolution/scripts/package-manager-family.mjs @@ -0,0 +1,176 @@ +// Package-type → `jf setup` package-manager family (Option C multi-package-manager +// zero-touch). +// +// Governance is keyed by Artifactory repo *type*; eager setup and rewrite +// guidance act on *package managers*. One type may own several (pypi → pip, +// pipenv, uv). Intersect with `jf setup --help` at runtime — this map is a +// ceiling, not a hardcode of CLI support. +// +// `twine` is intentionally excluded from the pypi family (publish-only; not +// part of zero-touch install routing). `yarn` and `poetry` are omitted (not +// first-class in Fly Desktop / product support). Gradle is its own Artifactory +// package type — not folded under maven. + +import { accessSync, constants, statSync } from "node:fs"; +import path from "node:path"; + +/** + * Artifactory package type → `jf setup` package-manager family + * (ceiling; intersect with CLI help). `twine` omitted from `pypi`. + * @type {Readonly>} + */ +export const TYPE_TO_PACKAGE_MANAGERS = Object.freeze({ + npm: Object.freeze(["npm", "pnpm"]), + pypi: Object.freeze(["pip", "pipenv", "uv"]), + maven: Object.freeze(["maven"]), + gradle: Object.freeze(["gradle"]), + go: Object.freeze(["go"]), + docker: Object.freeze(["docker", "podman"]), + helm: Object.freeze(["helm"]), + nuget: Object.freeze(["nuget", "dotnet"]), +}); + +/** + * `jf setup` package-manager token → PATH binary name(s). First hit wins. + * `pip` requires the pip CLI (`pip3`/`pip`) — `jf setup pip` runs + * `pip config set` (not a bare Python write). + * @type {Readonly>} + */ +const PACKAGE_MANAGER_BINARIES = Object.freeze({ + npm: ["npm"], + pnpm: ["pnpm"], + pip: ["pip3", "pip"], + pipenv: ["pipenv"], + uv: ["uv"], + maven: ["mvn"], + gradle: ["gradle"], + go: ["go"], + docker: ["docker"], + podman: ["podman"], + helm: ["helm"], + nuget: ["nuget"], + dotnet: ["dotnet"], +}); + +/** + * Package managers whose `jf setup` only writes config files (settings.xml / + * Gradle init) and never shells out to the client. Wrapper-only projects + * (`./mvnw`, `./gradlew`) must still get zero-touch config — do not PATH-gate. + * @type {ReadonlySet} + */ +const PACKAGE_MANAGERS_SETUP_WITHOUT_CLIENT = new Set(["maven", "gradle"]); + +/** + * Package managers to attempt for a governed package type (empty if unknown). + * @param {string} type Artifactory package type (e.g. `pypi`, `npm`) + * @returns {readonly string[]} `jf setup` package-manager tokens for that type + */ +export function packageManagersForType(type) { + return TYPE_TO_PACKAGE_MANAGERS[type] ?? []; +} + +/** + * Whether a package manager is eligible for eager `jf setup` w.r.t. client + * availability. Missing required binary → skip (warn); no failed receipt. + * + * Uses a PATH directory walk (no `which`/`where` spawn) so sessionStart can + * probe the full family without burning the hook budget. On Windows, also + * tries `PATHEXT` suffixes (`.cmd`, `.exe`, …). + * + * Test hooks: + * - `JFROG_TEST_ASSUME_PACKAGE_MANAGERS_PRESENT=1` → all present (unless listed missing) + * - `JFROG_TEST_MISSING_PACKAGE_MANAGERS=uv,pipenv` → force those absent + * Legacy aliases `JFROG_TEST_ASSUME_PMS_PRESENT` / `JFROG_TEST_MISSING_PMS` still work. + * + * @param {string} packageManager `jf setup` package-manager token + * @returns {boolean} + */ +export function packageManagerBinaryOnPath(packageManager) { + const missingRaw = + process.env.JFROG_TEST_MISSING_PACKAGE_MANAGERS || + process.env.JFROG_TEST_MISSING_PMS || + ""; + const missing = new Set( + missingRaw + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + ); + if (missing.has(String(packageManager).toLowerCase())) return false; + if ( + process.env.JFROG_TEST_ASSUME_PACKAGE_MANAGERS_PRESENT === "1" || + process.env.JFROG_TEST_ASSUME_PMS_PRESENT === "1" + ) { + return true; + } + + if (PACKAGE_MANAGERS_SETUP_WITHOUT_CLIENT.has(packageManager)) return true; + + const bins = PACKAGE_MANAGER_BINARIES[packageManager]; + if (!bins?.length) return false; + for (const bin of bins) { + if (binaryOnPath(bin)) return true; + } + return false; +} + +/** @type {string[] | null} */ +let cachedPathDirs = null; + +/** @returns {string[]} directories from `PATH` (cached for the process) */ +function pathDirs() { + if (cachedPathDirs) return cachedPathDirs; + cachedPathDirs = (process.env.PATH || "") + .split(path.delimiter) + .filter(Boolean); + return cachedPathDirs; +} + +/** + * Reset PATH cache (tests that mutate PATH between checks). + * @returns {void} + */ +export function resetPathCacheForTests() { + cachedPathDirs = null; +} + +/** + * Basenames to try for a command on this platform. + * Windows needs `npm.cmd` / `docker.exe` via PATHEXT; POSIX uses the bare name. + * @param {string} bin + * @returns {string[]} + */ +function pathCandidateNames(bin) { + if (process.platform !== "win32") return [bin]; + const lower = bin.toLowerCase(); + const exts = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM") + .split(";") + .map((e) => e.trim()) + .filter(Boolean); + if (exts.some((ext) => lower.endsWith(ext.toLowerCase()))) return [bin]; + return [bin, ...exts.map((ext) => bin + ext.toLowerCase())]; +} + +/** + * True if `bin` exists as an executable regular file in any PATH directory. + * On Windows, also matches `bin.cmd` / `bin.exe` via PATHEXT. + * @param {string} bin executable basename + * @returns {boolean} + */ +function binaryOnPath(bin) { + const names = pathCandidateNames(bin); + for (const dir of pathDirs()) { + for (const name of names) { + try { + const candidate = path.join(dir, name); + const st = statSync(candidate); + if (!st.isFile()) continue; + accessSync(candidate, constants.X_OK); + return true; + } catch { + // missing, not a file, not executable, or unreadable dir + } + } + } + return false; +} diff --git a/modules/package-resolution/scripts/print-policy.mjs b/modules/package-resolution/scripts/print-policy.mjs new file mode 100644 index 0000000..09f071a --- /dev/null +++ b/modules/package-resolution/scripts/print-policy.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// On-demand package-resolution policy printer. +// +// Unlike modules/*-session-start.mjs, this is NOT wired to a hook event. It is +// invoked manually (by the agent, per the pending notice) so a session that +// started "unconfigured" can load the up-to-date routing policy — resolved +// Artifactory URLs + hard rules — on demand once `jf` is configured. +// +// It delegates to the exact same `packageResolution.sessionStart(ctx)` the +// session-start hook runs, so recovery behaves identically to opening a fresh +// session: it warms ~/.jfrog/skills-cache/package-resolution.json AND triggers +// eager `jf setup` (background worker + receipt + lock) for auto-setup types. +// Safe to run repeatedly — the receipt/lock dedupe. print-policy is agent-invoked +// (not the 7s hook), so the background spawn is fine. +// +// Usage: node print-policy.mjs [workspaceRoot ...] +// workspaceRoot: dirs to consider for the .jfrog/local overlay; defaults to cwd. +// +// stdout: the same markdown the sessionStart hook would inject, or "" when +// routing is disabled/off (mode === "off"). + +import process from "node:process"; + +import packageResolution from "./index.mjs"; + +function parseWorkspaceRoots() { + const args = process.argv.slice(2); + return args.length ? args : [process.cwd()]; +} + +async function main() { + const workspaceRoots = parseWorkspaceRoots(); + const text = await packageResolution.sessionStart({ workspaceRoots }); + process.stdout.write(text?.trim() ? text : ""); +} + +main().catch((err) => { + process.stderr.write(`print-policy failed: ${err?.message ?? String(err)}\n`); + process.exit(1); +}); diff --git a/modules/package-resolution/scripts/render-instruction.mjs b/modules/package-resolution/scripts/render-instruction.mjs new file mode 100644 index 0000000..193f407 --- /dev/null +++ b/modules/package-resolution/scripts/render-instruction.mjs @@ -0,0 +1,508 @@ +// Render the package-resolution session-start instruction text. +// +// Extracted from the poc `inject-instructions.mjs` main(): this is the pure, +// harness-agnostic renderer. It returns a markdown STRING (no stdin/stdout, no +// IDE-specific shaping) so every per-harness adapter can reuse it. +// +// mode "off" → "" (nothing to inject) +// mode "pending" → the advisory "routing not ready" notice +// mode "routing" → the routing policy with resolved Artifactory URLs + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { + resolve as resolveRepo, + getResolveSessionMeta, + prepareSessionResolve, + governedPackageTypes, + getUnresolvedInfo, +} from "./resolver.mjs"; +import { createLogger } from "../../core/logger.mjs"; +import { globalDeclaredTypes } from "../../core/agents-config.mjs"; +import { IdentityCause } from "../../core/jf-identity.mjs"; + +const log = createLogger("render-instruction"); + +const here = path.dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = path.join(here, "../templates"); +const ROUTING_TEMPLATE = "package-resolution.md"; +const PENDING_TEMPLATE = "package-resolution-unconfigured.md"; + +// Command the agent runs after configuring `jf` to load routing in the SAME +// session (no restart). Absolute path so it works regardless of the agent's cwd +// or where the plugin is vendored. +function refreshCommand() { + return `node "${path.join(here, "print-policy.mjs")}"`; +} + +// Opening-clause fragment for the pending-notice {{CAUSE_INTRO}} placeholder. +// Kept in sync with causeRemediation / causeChecklist so the notice never +// contradicts itself (intro vs remediation vs numbered steps). +function causeIntro(cause) { + if (cause === IdentityCause.JF_NOT_INSTALLED) { + return "`jf` is not installed (or not on PATH)"; + } + if (cause === IdentityCause.JF_UNSUPPORTED_AUTH) { + return ( + "`jf` has a configured server, but its auth method is not supported " + + "(need an access token or username + password / API key)" + ); + } + 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)"; + } + return "`jf` has no configured server"; +} + +// Prose fragment for the pending-notice {{CAUSE_REMEDIATION}} placeholder. +function causeRemediation(cause) { + if (cause === IdentityCause.JF_NOT_INSTALLED) { + return ( + "Begin by installing the JFrog CLI (`jf`) and adding it to PATH, then " + + "configure a JFrog server by following the login flow in the base " + + "`jfrog` skill." + ); + } + if (cause === IdentityCause.JF_UNSUPPORTED_AUTH) { + return ( + "The JFrog CLI is installed and a server is configured, but Agent " + + "Package Resolution only supports access-token or username + password " + + "/ API-key auth. Reconfigure with `jf config add` using one of those " + + "methods (SSH-key-only servers are not supported)." + ); + } + if (cause === IdentityCause.JF_AUTH_FAILED) { + return ( + "The JFrog CLI is installed and a server is configured, but Artifactory " + + "rejected the credentials. Refresh the access token or password / API " + + "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 " + + "did not answer a readiness probe. Confirm the platform URL, network, " + + "and that Artifactory is up, then retry." + ); + } + return ( + "The JFrog CLI is installed and ready. Configure a JFrog server by " + + "following the login flow in the base `jfrog` skill to finish enabling " + + "routing." + ); +} + +// Numbered steps for {{CAUSE_CHECKLIST}}. When jf is already present, omit the +// "Confirm jf is installed" step so it does not contradict remediation. +function causeChecklist(cause) { + const configure = + "Configure a JFrog server (login flow or `jf config add` with access " + + "token or username + password / API key);\n" + + " confirm with `jf config show`."; + const reconfigure = + "Reconfigure the server with a supported auth method (`jf config add` " + + "with access token or username + password / API key);\n" + + " confirm with `jf config show`."; + 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."; + const setup = + "Invoke **`jfrog-setup-package-managers`** to bind package managers this workspace needs."; + if (cause === IdentityCause.JF_NOT_INSTALLED) { + return ( + "1. Confirm `jf` is installed (`jf --version`).\n" + + `2. ${configure}\n` + + `3. ${setup}` + ); + } + if (cause === IdentityCause.JF_UNSUPPORTED_AUTH) { + return `1. ${reconfigure}\n2. ${setup}`; + } + 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}`; + } + return `1. ${configure}\n2. ${setup}`; +} + +function jfrogPlatformUrlHint() { + const raw = process.env.JFROG_PLATFORM_URL?.trim(); + if (!raw) { + return ( + "When configuring `jf`, check whether `JFROG_PLATFORM_URL` is set in the " + + "IDE launch environment and use it as the platform URL (`jfrog-login-flow.md`)." + ); + } + return ( + "IDE launch env `JFROG_PLATFORM_URL` is `" + + raw + + "` — use this when configuring `jf` (web login or `jf config add --url`; " + + "prefix `https://` if the value is hostname-only)." + ); +} + +/** + * Human-readable category for a resolver failure cause. Every cause used to + * render as "was rejected by Artifactory", which is wrong for `unreachable` + * (Artifactory never responded), 401/403 (a credentials/permissions problem, + * not the repo key), and 5xx (a service failure) — telling the agent + * "rejected" for those steers it toward replacing a valid repo key instead + * of fixing connectivity/auth/service health. The raw cause token stays + * visible alongside this as secondary detail. + * @param {string} cause + * @returns {string} + */ +function causeDescription(cause) { + if (cause === "not-found") return "was not found in Artifactory"; + if (cause === "package-type-mismatch") + return "resolved, but as a different package type"; + if (cause === "unreachable") + return "did not receive a response from Artifactory"; + if (cause === "insecure-url") + return "cannot be verified over a non-HTTPS platform URL"; + if (cause === "jf-unsupported-auth") + return "cannot be verified — the configured jf auth method is unsupported"; + if (cause === "jf-not-configured") + return "cannot be verified — no JFrog server is configured"; + if (cause === "unresolved-cached") + return "did not resolve in the last verify pass (served from cache)"; + const status = Number(/^http-(\d+)$/.exec(cause)?.[1]); + if (status === 401 || status === 403) { + return "was rejected — check jf credentials/permissions, not the repo key"; + } + if (status >= 500) return "Artifactory returned a server error"; + if (status) return "Artifactory returned an unexpected response"; + return "could not be verified"; +} + +/** + * Governed-but-unresolved table-cell text for `type`. Prefers the real verify + * failure cause over a blank `` — an unfilled-looking + * placeholder next to an onboarding nudge framing routing as opt-in is + * exactly the contradiction that let an agent read "unresolved" as + * "unconfigured, decline if you like" instead of "blocked, do not proceed". + * Kept short — full detail lives once in the leading BLOCKED block, not + * repeated here (both types are unresolved for the same reason). + * Plain prose, not a code value — the table renders it without backticks + * (unlike a real URL) so a repo key/cause can't produce nested/broken + * backticks inside the markdown table cell. + * @param {string} type + * @returns {string} + */ +const NO_REPO = (type) => { + const info = getUnresolvedInfo(type); + if (info?.cause) { + return `NOT ROUTED (${info.cause}) — see BLOCKED note above`; + } + return ``; +}; + +/** + * Builds the "Resolved URLs" markdown table, one row per governed type. + * Ungoverned types are omitted entirely; governed-but-unresolved types keep + * a placeholder row so hard-rule #5 can steer the agent to setup. + * @param {string[]} governed + * @param {Record} resolved + * @returns {string} + */ +function buildResolvedTable(governed, resolved) { + const rows = governed.map((type) => { + const r = resolved[type]; + const cell = r ? `\`${r.baseUrl}\`` : NO_REPO(type); + return `| ${type} | ${cell} |`; + }); + return ["| Type | Use this URL |", "|---|---|", ...rows].join("\n"); +} + +/** + * Renders one unmissable blocking line per governed-but-unresolved type, + * placed ABOVE the scope paragraph and tables ("do not install" previously + * only appeared buried inside a 7-branch Decision table and an 8-item hard- + * rule list — nothing said it up front). Returns "" when every governed + * type resolved, so the common case adds nothing. + * @param {string[]} governed + * @param {Record} resolved + * @returns {string} + */ +function buildUnresolvedBlock(governed, resolved) { + const unresolvedTypes = governed.filter((type) => !resolved[type]); + if (!unresolvedTypes.length) return ""; + // "Do not install / invoke the setup skill" is stated once, generically, + // by Decision step 1 below — not repeated per type here, or this block + // (and the token budget) grows with every unresolved type. + const lines = unresolvedTypes.map((type) => { + const info = getUnresolvedInfo(type); + const detail = info + ? `the configured repo \`${info.repoKey}\` ${causeDescription(info.cause)} (${info.cause})` + : "no repo could be resolved for it"; + return `**${type} is BLOCKED — not routed.** ${detail}.`; + }); + return "\n" + lines.join("\n\n") + "\n"; +} + +// Per-type "## Rewrite templates" bullet(s). Unresolved governed types get the +// "do not invent a URL" bullet instead so the agent never sees a wrong example. +function rewriteBulletFor(type, resolved) { + const r = resolved[type]; + if (!r) { + return `- \`${type}\` — unresolved, see BLOCKED note above.`; + } + const url = r.baseUrl; + switch (type) { + case "npm": + return ( + `- \`npm install \` → \`npm install --registry ${url}\`\n` + + `- \`pnpm add \` / \`pnpm install\` → \`pnpm add --registry ${url}\`` + ); + case "pypi": + return ( + `- \`pip install \` → \`pip install --index-url ${url}\`\n` + + `- \`pipenv install \` → \`pipenv install --pypi-mirror ${url}\`\n` + + `- \`uv add \` → \`UV_DEFAULT_INDEX=${url} uv add \` (or \`uv add --default-index ${url} \`)\n` + + `- \`uv pip install \` → \`uv pip install --index-url ${url}\`` + ); + case "go": + return `- \`go get \` → \`GOPROXY=${url},direct go get \``; + case "docker": + return ( + `- \`docker pull [/]acme/app:1.2\` → \`docker pull ${url}/acme/app:1.2\` ` + + `(drop 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.`; + case "gradle": + return `- \`gradle ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; + case "helm": + return `- \`helm ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; + case "nuget": + return `- \`nuget\` / \`dotnet ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; + default: + return `- \`${type} ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; + } +} + +function buildRewriteBullets(governed, resolved) { + return governed.map((type) => rewriteBulletFor(type, resolved)).join("\n"); +} + +// The "## Docker" section, rendered ONLY when docker is governed. Empty string +// otherwise so ungoverned docker never appears in the policy. +function buildDockerSection(governed, resolved) { + if (!governed.includes("docker")) return ""; + const resolvedDocker = resolved.docker; + const body = resolvedDocker + ? [ + "- **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.** 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 +// auto-configured once routing is ready. Does NOT claim any type is routed yet. +function buildPendingGovernedScope() { + const governed = globalDeclaredTypes(); + if (!governed.length) { + return ( + "No package managers are declared for routing yet (`defaultGlobalRepos` is empty). " + + "Ask an admin which package managers to govern." + ); + } + return ( + `**Governed package managers (once ready):** ${governed.join(", ")}. ` + + "Package managers not listed are out of scope. Matching package managers may be auto-configured " + + "via `jf setup` once a JFrog server is configured; nothing is routed until then." + ); +} + +// "This policy governs only: …" scope line so the agent knows which package managers are in +// scope and treats everything else as hands-off. +function buildGovernedScope(governed) { + if (!governed.length) { + return ( + "**This policy governs no package managers** (none declared in " + + "`defaultGlobalRepos`). Install packages normally; no JFrog routing required." + ); + } + return ( + `**This policy governs only:** ${governed.join(", ")}. ` + + "Package managers not listed are out of scope — install them normally; no JFrog routing required." + ); +} + +/** + * Render the instruction text for a resolved feature-flag result. + * + * Returns BOTH the markdown and a flat `meta` object describing what happened + * (cause / resolved repos / cache file / source …). The dispatcher folds `meta` + * into its single "sessionStart injected" EVENT line so the default-level log + * stays one line but still carries the detail the POC printed. + * + * @param {{ mode: "off"|"pending"|"routing", cause?: string }} flag + * @param {{ workspaceRoots?: string[] }} [ctx] + * @returns {Promise<{ text: string, meta: object }>} text is "" when there is + * nothing to inject. + */ +export async function renderInstruction(flag, ctx = {}) { + if (!flag || flag.mode === "off") return { text: "", meta: { mode: "off" } }; + + if (flag.mode === "pending") { + let notice = await readFile( + path.join(TEMPLATES_DIR, PENDING_TEMPLATE), + "utf8", + ); + notice = notice.replace(/\{\{CAUSE_INTRO\}\}/g, causeIntro(flag.cause)); + notice = notice.replace( + /\{\{CAUSE_REMEDIATION\}\}/g, + causeRemediation(flag.cause), + ); + notice = notice.replace( + /\{\{CAUSE_CHECKLIST\}\}/g, + causeChecklist(flag.cause), + ); + notice = notice.replace( + /\{\{JFROG_PLATFORM_URL_HINT\}\}/g, + jfrogPlatformUrlHint(), + ); + notice = notice.replace(/\{\{REFRESH_COMMAND\}\}/g, refreshCommand()); + notice = notice.replace( + /\{\{GOVERNED_SCOPE\}\}/g, + buildPendingGovernedScope(), + ); + // Detail line — kept at debug so the default level shows a single EVENT per + // session (the dispatcher's "sessionStart injected"). Raise the level to see + // the cause/byte breakdown. + log.debug("pending notice rendered", { + cause: flag.cause, + bytes: notice.length, + }); + return { + text: notice, + meta: { cause: flag.cause, template: PENDING_TEMPLATE }, + }; + } + + // 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 }); + const governed = governedPackageTypes(); + const resolved = {}; + const unresolved = []; + for (const t of governed) { + const r = await resolveRepo(t); + if (r) resolved[t] = r; + else unresolved.push(t); + } + + let template = await readFile( + path.join(TEMPLATES_DIR, ROUTING_TEMPLATE), + "utf8", + ); + template = template + .replace( + /\{\{UNRESOLVED_BLOCK\}\}/g, + buildUnresolvedBlock(governed, resolved), + ) + .replace(/\{\{GOVERNED_SCOPE\}\}/g, buildGovernedScope(governed)) + .replace(/\{\{RESOLVED_TABLE\}\}/g, buildResolvedTable(governed, resolved)) + .replace( + /\{\{REWRITE_BULLETS\}\}/g, + 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` : "", + ); + + const resolvedCompact = + Object.entries(resolved) + .map(([t, r]) => `${t}:${r.repoKey}`) + .join(",") || "-"; + const unresolvedCompact = unresolved.join(",") || "-"; + + const rm = getResolveSessionMeta(); + // Detail line — kept at debug (see the pending branch above) so the default + // level shows a single EVENT per session. + log.debug("routing instruction rendered", { + governed: governed.join(",") || "-", + resolved: resolvedCompact, + unresolved: unresolvedCompact, + source: rm?.source ?? "-", + bytes: template.length, + }); + + const meta = { + source: rm?.source ?? "-", + serverId: rm?.serverId ?? "-", + cacheFile: rm?.cacheFile ?? "-", + cacheHit: rm?.cacheHit ?? false, + resolveSource: rm?.resolveSource ?? "-", + governed: governed.join(",") || "-", + resolved: resolvedCompact, + unresolved: unresolvedCompact, + template: ROUTING_TEMPLATE, + }; + + // Workspace fields only when a local file was read and applied to resolution. + if (rm?.workspaceConfigFile) { + meta.workspaceRootsCount = rm.workspaceRootsCount; + meta.workspaceConfigFile = rm.workspaceConfigFile; + meta.workspaceOverrides = rm.workspaceOverrides; + } + + return { text: template, meta }; +} diff --git a/modules/package-resolution/scripts/repo-types.mjs b/modules/package-resolution/scripts/repo-types.mjs new file mode 100644 index 0000000..e72f0d0 --- /dev/null +++ b/modules/package-resolution/scripts/repo-types.mjs @@ -0,0 +1,35 @@ +// Package-type constants shared by resolver and workspace overlay. + +export const PACKAGE_TYPES = [ + "npm", + "pypi", + "maven", + "gradle", + "go", + "docker", + "helm", + "nuget", +]; + +const SAFE_REPO_KEY = /^[A-Za-z0-9._-]+$/; + +export function isSafeRepoKey(value) { + return typeof value === "string" && SAFE_REPO_KEY.test(value); +} + +const TYPE_PACKAGE_TYPE = { + npm: "npm", + pypi: "pypi", + maven: "maven", + gradle: "gradle", + go: "go", + docker: "docker", + helm: "helm", + nuget: "nuget", +}; + +export function repoMatchesPackageType(config, type) { + const expected = TYPE_PACKAGE_TYPE[type]; + if (!expected || !config?.packageType) return true; + return String(config.packageType).toLowerCase() === expected; +} diff --git a/modules/package-resolution/scripts/resolver.mjs b/modules/package-resolution/scripts/resolver.mjs new file mode 100644 index 0000000..71e4d84 --- /dev/null +++ b/modules/package-resolution/scripts/resolver.mjs @@ -0,0 +1,733 @@ +// Repo resolver — maps package type → Artifactory repo key (+ URL for the +// session-policy instruction injection and the jf-setup skill). +// +// Session resolution (once per hook process, per jf server id). +// Identity comes from a separate local `jf config export` (always runs; cheap). +// This module only controls Artifactory HTTP: +// 1. Valid local cache ~/.jfrog/skills-cache/package-resolution.json → no HTTP +// 2. Else read defaultGlobalRepos from ~/.jfrog/agents-conf.json +// 3. Optional verify via GET …/api/repositories/{key} (verifyRepos, default true) +// 4. Write snapshot to cache file (TTL from agents-conf.json cacheTtlDays) + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; + +import { createLogger } from "../../core/logger.mjs"; +import { + getAgentsConfigMtimeMs, + loadAgentsConfig, + globalDeclaredTypes, +} from "../../core/agents-config.mjs"; +import { + getPlatformIdentity, + authHeader, + isHttpsIdentityUrl, + safeErrorMessage, +} from "../../core/jf-identity.mjs"; +import { skillsProductUserAgent } from "../../core/jf-user-agent.mjs"; +import { PACKAGE_TYPES, repoMatchesPackageType } from "./repo-types.mjs"; +import { + pickWorkspaceConfigRoot, + loadWorkspaceConfig, +} from "./workspace-config.mjs"; + +const log = createLogger("resolver"); + +function cacheDir() { + return path.join(homedir(), ".jfrog", "skills-cache"); +} + +function cacheFile() { + return path.join(cacheDir(), "package-resolution.json"); +} + +const CACHE_SCHEMA_VERSION = 2; +// One shared window covers admin and workspace verification. Keeping the +// window below the shortest existing harness timeout prevents sequential +// verification phases from consuming the entire SessionStart budget. +const REPO_VERIFY_BUDGET_MS = 5_000; + +/** In-process snapshot after first resolve pass in this hook invocation. */ +const SESSION = { + serverId: null, + meta: null, + byType: null, + workspaceDeclaredTypes: [], + overlayPreparedFor: null, + // Admin-configured types that failed this session's verify, with why — + // so the rendered instruction can say WHY a type is unrouted instead of + // rendering an unfilled-looking `` placeholder. + unresolvedCauses: {}, +}; + +function identityOrNull() { + return getPlatformIdentity().identity; +} + +function effectiveServerId(hint, identity = identityOrNull()) { + if (hint) return hint; + if (identity?.serverId) return identity.serverId; + // A URL is stable for an identity with no JFrog CLI server id, unlike a + // shared literal "default" key that can leak cache state across servers. + return identity?.url ? `url:${identity.url}` : "default"; +} + +function packageResolveSource(serverId, { via } = {}) { + const suffix = via ? ` via=${via}` : ""; + return `package-resolution:${cacheFile()}#${serverId}${suffix}`; +} + +/** Last session-wide resolve metadata (for inject-instructions EVENT log). */ +export function getResolveSessionMeta() { + return SESSION.meta; +} + +function urlFor(type, repoKey, base) { + switch (type) { + case "npm": + return `${base}/api/npm/${repoKey}/`; + case "pypi": + return `${base}/api/pypi/${repoKey}/simple/`; + case "maven": + case "gradle": + return `${base}/${repoKey}/`; + case "go": + return `${base}/api/go/${repoKey}`; + case "docker": + return new URL(base).host + "/" + repoKey; + case "helm": + return `${base}/${repoKey}/`; + case "nuget": + return `${base}/api/nuget/v3/${repoKey}/index.json`; + default: + return `${base}/${repoKey}/`; + } +} + +async function readCacheFile() { + const file = cacheFile(); + try { + const raw = await readFile(file, "utf8"); + return { data: JSON.parse(raw), file }; + } catch { + return { data: null, file }; + } +} + +async function writeCacheFile(root) { + const file = cacheFile(); + const payload = { + schemaVersion: CACHE_SCHEMA_VERSION, + servers: root.servers ?? {}, + }; + const creating = !existsSync(file); + await mkdir(cacheDir(), { recursive: true }); + await writeFile(file, JSON.stringify(payload, null, 2)); + if (creating) { + log.info("created global cache file", { cache: file }); + } +} + +function normalizeServerEntry(entry) { + if (!entry?.repositories || typeof entry.repositories !== "object") + return null; + return { + repositories: { ...entry.repositories }, + cached_at: entry.cached_at, + source: entry.source, + agentsConfigMtimeMs: entry.agentsConfigMtimeMs, + url: typeof entry.url === "string" ? entry.url : null, + }; +} + +function isEntryFresh(entry, agentsConfigMtimeMs, cacheTtlDays, url) { + if (!entry?.cached_at) return false; + if (cacheTtlDays === 0) return false; + if (entry.agentsConfigMtimeMs !== agentsConfigMtimeMs) return false; + // Schema-1 entries have no URL. Refresh them once instead of trusting an + // entry verified against a server the user may have switched away from. + if (!entry.url || entry.url !== url) return false; + const ttlMs = cacheTtlDays * 24 * 60 * 60 * 1000; + const age = Date.now() - new Date(entry.cached_at).getTime(); + return age >= 0 && age < ttlMs; +} + +/** Normalize on-disk cache to `{ schemaVersion, servers }` (migrates legacy flat layout). */ +function normalizeCacheRoot(data) { + const servers = {}; + if (!data || typeof data !== "object") { + return { schemaVersion: CACHE_SCHEMA_VERSION, servers }; + } + if (data.servers && typeof data.servers === "object") { + for (const [serverId, entry] of Object.entries(data.servers)) { + const normalized = normalizeServerEntry(entry); + if (normalized) servers[serverId] = normalized; + } + return { + schemaVersion: + typeof data.schemaVersion === "number" + ? data.schemaVersion + : CACHE_SCHEMA_VERSION, + servers, + }; + } + for (const [key, val] of Object.entries(data)) { + if (key === "schemaVersion") continue; + const normalized = normalizeServerEntry(val); + if (normalized) servers[key] = normalized; + } + return { schemaVersion: CACHE_SCHEMA_VERSION, servers }; +} + +/** + * @returns {Promise<{ config: object|null, cause: string|null }>} cause is set + * whenever config is null, so callers can report WHY verify failed instead + * of silently collapsing every failure mode to the same blank miss. + */ +async function fetchRepoConfig(repoKey, id, deadline) { + if (!id) return { config: null, cause: "jf-not-configured" }; + if (!isHttpsIdentityUrl(id)) { + log.warn("refusing repo verify over a non-HTTPS platform URL", { repoKey }); + return { config: null, cause: "insecure-url" }; + } + 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. + log.info("verifying repo via Artifactory API", { repoKey, url }); + const authorization = authHeader(id); + if (!authorization) return { config: null, cause: "jf-unsupported-auth" }; + // Bound the call so a stalled Artifactory can't hang session start. + const controller = new AbortController(); + const remaining = Math.max(0, deadline - Date.now()); + const timer = setTimeout(() => controller.abort(), remaining); + try { + const res = await fetch(url, { + headers: { + Authorization: authorization, + Accept: "application/json", + "User-Agent": skillsProductUserAgent(), + }, + signal: controller.signal, + }); + if (!res.ok) { + const cause = res.status === 404 ? "not-found" : `http-${res.status}`; + log.debug("repo verify miss", { repoKey, status: res.status, cause }); + return { config: null, cause }; + } + return { config: await res.json(), cause: null }; + } catch (err) { + log.warn("repo verify threw", { + repoKey, + error: safeErrorMessage(err), + }); + return { config: null, cause: "unreachable" }; + } finally { + clearTimeout(timer); + } +} + +function buildResolveMeta(serverId, entry, { via, cacheFile }) { + return { + serverId, + source: packageResolveSource(serverId, { via }), + cacheFile, + resolveSource: entry.source ?? via, + cached_at: entry.cached_at, + cacheHit: via === "cache", + }; +} + +function entryToByType(entry, base) { + const byType = {}; + for (const [type, repoKey] of Object.entries(entry.repositories ?? {})) { + if (!repoKey) continue; + byType[type] = { + type, + repoKey, + baseUrl: urlFor(type, repoKey, base), + }; + } + return byType; +} + +/** + * Verifies each admin-configured repo against Artifactory and writes the + * resolved set to the on-disk cache, preserving prior good entries on + * partial/total verify failure and recording why each failed type failed. + */ +async function refreshServerCache( + serverId, + id = identityOrNull(), + verifyDeadline = Date.now() + REPO_VERIFY_BUDGET_MS, +) { + const base = id ? `${id.url}/artifactory` : ""; + const repositories = {}; + const pr = loadAgentsConfig().packageResolution; + const verifyRepos = pr.verifyRepos; + const adminRepos = pr.defaultGlobalRepos ?? {}; + const agentsConfigMtimeMs = getAgentsConfigMtimeMs(); + const configured = PACKAGE_TYPES.flatMap((type) => { + const repoKey = adminRepos[type]; + if (!repoKey) { + log.debug("unconfigured type", { type }); + return []; + } + return [{ type, repoKey }]; + }); + const adminConfiguredCount = configured.length; + + // Reset per session-refresh — only this pass's own verify attempts should + // explain "unresolved" to the render; a stale cause from an earlier refresh + // in the same process must not outlive the config that produced it. + SESSION.unresolvedCauses = {}; + + if (verifyRepos) { + // Each repository lookup is independent. Parallel verification keeps a + // cold session within the hook's 15-second budget instead of multiplying + // the five-second request timeout by every configured package type. + const verified = await Promise.all( + configured.map(async ({ type, repoKey }) => { + const { config, cause: fetchCause } = await fetchRepoConfig( + repoKey, + id, + verifyDeadline, + ); + const matchesType = + Boolean(config) && repoMatchesPackageType(config, type); + return { + type, + repoKey, + verified: matchesType, + cause: config && !matchesType ? "package-type-mismatch" : fetchCause, + }; + }), + ); + for (const { type, repoKey, verified: isVerified, cause } of verified) { + if (!isVerified) { + log.warn("repo verify failed", { type, repoKey, serverId, cause }); + SESSION.unresolvedCauses[type] = { repoKey, cause }; + continue; + } + repositories[type] = repoKey; + log.debug("resolved from agents-conf.json (verified)", { type, repoKey }); + } + } else { + for (const { type, repoKey } of configured) { + repositories[type] = repoKey; + log.debug("resolved from agents-conf.json (trusted)", { type, repoKey }); + } + } + + const source = verifyRepos ? "verified" : "agents-config"; + + const { data: cacheRoot, file } = await readCacheFile(); + const root = normalizeCacheRoot(cacheRoot); + const priorEntry = root.servers[serverId]; + // serverId alone does not guarantee identity: a named jf server can be + // repointed at a different URL without its serverId changing. Combining + // an old, never-verified-against-this-host repo key with the CURRENT + // base URL would route an install through an unverified repository. + const priorEntryUrlMatches = priorEntry?.url === (id?.url ?? null); + const priorHasRepos = Boolean( + priorEntryUrlMatches && + priorEntry?.repositories && + Object.keys(priorEntry.repositories).length, + ); + + // A total verify failure (every admin-configured type failed the repo + // check — e.g. Artifactory briefly unreachable) must not pin an empty + // `repositories: {}` with a fresh `cached_at` for the full TTL: + // - prior good entry → keep it (and its cached_at) + // - no prior → skip writeCacheFile so the next session retries verify + if ( + verifyRepos && + adminConfiguredCount > 0 && + Object.keys(repositories).length === 0 + ) { + if (priorHasRepos) { + log.warn( + "repo verify failed for every configured type — keeping prior cache " + + "entries whose key still matches the current config", + { serverId, configuredCount: adminConfiguredCount }, + ); + // Only trust a prior key for a type if it is still the key the admin + // currently configures. If defaultGlobalRepos changed the key since + // the cache was written, restoring the old one would route installs + // through a repository the current config no longer authorizes — the + // type stays unresolved instead, keeping the cause this pass recorded. + const staleRepositories = {}; + for (const [type, repoKey] of Object.entries(priorEntry.repositories)) { + if (!repoKey || repoKey !== adminRepos[type]) continue; + staleRepositories[type] = repoKey; + delete SESSION.unresolvedCauses[type]; + } + const staleEntry = { ...priorEntry, repositories: staleRepositories }; + SESSION.serverId = serverId; + SESSION.byType = entryToByType(staleEntry, base); + SESSION.meta = buildResolveMeta(serverId, staleEntry, { + via: "refresh-verify-failed-kept-prior", + cacheFile: file, + }); + return; + } + log.warn( + "repo verify failed for every configured type — skipping empty cache " + + "write so the next session retries verification", + { serverId, configuredCount: adminConfiguredCount }, + ); + const empty = { + repositories: {}, + cached_at: new Date().toISOString(), + source, + agentsConfigMtimeMs, + url: id?.url ?? null, + }; + SESSION.serverId = serverId; + SESSION.byType = {}; + SESSION.meta = buildResolveMeta(serverId, empty, { + via: "refresh-verify-failed-no-cache", + cacheFile: file, + }); + return; + } + + // Partial verify failure: keep prior keys for admin-configured types that + // failed this round so a transient blip on one type does not ungover that + // type for the full cache TTL. + if (verifyRepos && priorHasRepos) { + for (const [type, repoKey] of Object.entries(priorEntry.repositories)) { + if (repositories[type] || !adminRepos[type]) continue; + // Only restore this cached key if it's still the key the admin + // currently configures — a changed key means the current config no + // longer authorizes it, so the type stays unresolved instead. + if (repoKey !== adminRepos[type]) continue; + repositories[type] = repoKey; + // Type is governed again via the stale-but-trusted cache entry — the + // cause captured above no longer describes its current (resolved) state. + delete SESSION.unresolvedCauses[type]; + log.warn("repo verify failed — keeping prior cache value for type", { + type, + repoKey, + serverId, + }); + } + } + + const entry = { + repositories, + cached_at: new Date().toISOString(), + source, + agentsConfigMtimeMs, + url: id?.url ?? null, + }; + + root.servers[serverId] = entry; + await writeCacheFile(root); + + const via = verifyRepos ? "refresh-verified" : "refresh-agents-config"; + SESSION.serverId = serverId; + SESSION.byType = entryToByType(entry, base); + SESSION.meta = buildResolveMeta(serverId, entry, { via, cacheFile: file }); + log.debug("cache refreshed", { + serverId, + source, + resolved: Object.keys(repositories).join(","), + cache: file, + }); +} + +/** + * Returns the cached server entry if it exists and is still fresh + * (TTL/mtime/URL all match), populating SESSION from it with no network call. + */ +async function loadFreshCacheEntry(serverId, id = identityOrNull()) { + const pr = loadAgentsConfig().packageResolution; + const agentsConfigMtimeMs = getAgentsConfigMtimeMs(); + const { data, file } = await readCacheFile(); + const entry = normalizeServerEntry( + normalizeCacheRoot(data).servers[serverId], + ); + if ( + !entry || + !isEntryFresh(entry, agentsConfigMtimeMs, pr.cacheTtlDays, id?.url ?? "") + ) + return null; + + const base = id ? `${id.url}/artifactory` : ""; + SESSION.serverId = serverId; + SESSION.byType = entryToByType(entry, base); + // No verify ran this pass, so there's no fresh cause to report — but a + // type that is still admin-configured and absent from this cached + // snapshot is still unresolved for the whole cache TTL. Reconstruct + // enough to say so, rather than silently reverting to the ambiguous + // `` placeholder for the rest of the TTL. + SESSION.unresolvedCauses = {}; + const adminRepos = pr.defaultGlobalRepos ?? {}; + for (const [type, repoKey] of Object.entries(adminRepos)) { + if (!repoKey || SESSION.byType[type]) continue; + SESSION.unresolvedCauses[type] = { repoKey, cause: "unresolved-cached" }; + } + SESSION.meta = buildResolveMeta(serverId, entry, { + via: "cache", + cacheFile: file, + }); + log.debug("cache hit", { + serverId, + source: entry.source, + ageMs: Date.now() - new Date(entry.cached_at).getTime(), + cache: file, + }); + return entry; +} + +/** + * Guarantees SESSION holds a resolved (cache-hit or freshly-verified) repo + * map for the current server id, short-circuiting if already resolved this + * process. + */ +async function ensureSessionResolved( + serverIdHint, + verifyDeadline = Date.now() + REPO_VERIFY_BUDGET_MS, +) { + 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; + // A prior identity's verify failure must not be reported as this one's. + SESSION.unresolvedCauses = {}; + return; + } + + const id = rawId; + const serverId = effectiveServerId(serverIdHint, id); + if (SESSION.serverId === serverId && SESSION.byType) return; + + const cached = await loadFreshCacheEntry(serverId, id); + if (cached) return; + + await refreshServerCache(serverId, id, verifyDeadline); +} + +function workspaceOverlayMetaApplied(workspaceRoots, pick, overridden) { + return { + workspaceRootsCount: workspaceRoots.length, + workspaceConfigFile: pick.configFile, + workspaceOverrides: overridden.join(","), + }; +} + +/** + * Overlays workspace-local `.jfrog/local` repo declarations onto the + * session's resolved types, verifying each declared repo before it + * overrides the global mapping. + */ +async function applyWorkspaceOverlay( + workspaceRoots, + verifyDeadline = Date.now() + REPO_VERIFY_BUDGET_MS, +) { + SESSION.workspaceDeclaredTypes = []; + const roots = workspaceRoots?.length ? workspaceRoots : []; + const pick = pickWorkspaceConfigRoot(roots); + + if (!pick) return; + + const ws = await loadWorkspaceConfig(pick); + if (ws.status === "invalid" || ws.status === "unreadable") { + // The file exists and was meant to take effect; ignoring it silently makes + // a typo (e.g. a trailing comma) look like a resolution failure. Warn so it + // surfaces regardless of log level. + log.warn("workspace config ignored", { + reason: ws.status, + file: pick.configFile, + error: ws.error?.message, + }); + return; + } + if (ws.status !== "ok") { + log.debug("workspace overlay skipped", { + reason: ws.status, + root: pick.root, + }); + return; + } + + 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 overridden = []; + const declared = []; + + const requested = Object.entries(ws.config.repositories).flatMap( + ([type, repoKey]) => { + if (!repoKey || !PACKAGE_TYPES.includes(type)) return []; + return [{ type, repoKey }]; + }, + ); + + const validated = pr.verifyRepos + ? await Promise.all( + requested.map(async ({ type, repoKey }) => { + const { config } = await fetchRepoConfig(repoKey, id, verifyDeadline); + return { + type, + repoKey, + verified: Boolean(config && repoMatchesPackageType(config, type)), + }; + }), + ) + : requested.map(({ type, repoKey }) => ({ type, repoKey, verified: true })); + + for (const { type, repoKey, verified } of validated) { + if (!verified) { + log.warn("workspace repo verify failed", { + type, + repoKey, + file: pick.configFile, + }); + 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", + root: pick.root, + }); + return; + } + + const hadGlobal = SESSION.meta?.resolveSource; + SESSION.meta = { + ...SESSION.meta, + ...workspaceOverlayMetaApplied(roots, pick, overridden), + resolveSource: hadGlobal ? "mixed-workspace" : "workspace-override", + }; + + log.debug("workspace overlay applied", { + root: pick.root, + file: pick.configFile, + overridden: overridden.join(","), + }); +} + +/** + * Global cache resolve + optional workspace-local overlay (first root with a config file). + * 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 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 union = new Set([ + ...globalDeclaredTypes(), + ...(SESSION.workspaceDeclaredTypes ?? []), + ]); + return PACKAGE_TYPES.filter((type) => union.has(type)); +} + +/** + * Why an admin-configured governed type failed THIS session's verify, if + * known. Render uses this to say what actually happened (rejected repo key, + * unreachable Artifactory, …) instead of an ambiguous blank placeholder. + * @param {string} type + * @returns {{ repoKey: string, cause: string }|null} + */ +export function getUnresolvedInfo(type) { + return SESSION.unresolvedCauses?.[type] ?? null; +} + +export async function resolve(type, { serverId: serverIdHint } = {}) { + log.debug("resolve start", { + type, + serverId: effectiveServerId(serverIdHint), + }); + + await ensureSessionResolved(serverIdHint); + + const hit = SESSION.byType?.[type]; + if (!hit) { + log.debug("resolve miss", { type }); + return null; + } + + const result = { + ...hit, + source: SESSION.meta?.source ?? "unknown", + serverId: SESSION.meta?.serverId, + cacheFile: SESSION.meta?.cacheFile, + }; + log.debug("resolved", result); + return result; +} + +/** Force cache refresh (e.g. tests or future --refresh flag). */ +export async function invalidateResolveCache(serverIdHint) { + SESSION.serverId = null; + SESSION.byType = null; + SESSION.meta = null; + SESSION.workspaceDeclaredTypes = []; + SESSION.overlayPreparedFor = null; + SESSION.unresolvedCauses = {}; + const serverId = effectiveServerId(serverIdHint); + const { data } = await readCacheFile(); + const root = normalizeCacheRoot(data); + if (root.servers[serverId]) { + delete root.servers[serverId]; + await writeCacheFile(root); + } +} + +const isMain = import.meta.url === `file://${process.argv[1]}`; +if (isMain) { + const type = process.argv[2]; + if (!type) { + console.error("usage: node lib/resolver.mjs "); + console.error(" types: npm pypi maven gradle go docker helm nuget"); + process.exit(1); + } + const result = await resolve(type); + if (!result) { + console.error(`No repo resolved for type=${type}.`); + console.error( + "Live mode needs a configured `jf` server (access token or username + password / API key; run `jf c add`).", + ); + process.exit(2); + } + console.log(JSON.stringify(result, null, 2)); +} diff --git a/modules/package-resolution/scripts/setup-conflict.mjs b/modules/package-resolution/scripts/setup-conflict.mjs new file mode 100644 index 0000000..65ac06c --- /dev/null +++ b/modules/package-resolution/scripts/setup-conflict.mjs @@ -0,0 +1,807 @@ +// Detect when zero-touch `jf setup` would silently repoint an existing +// user-level package-manager config at a different Artifactory (or public +// registry). Fail-safe: skip that package manager and surface it in the +// session note — never auto-overwrite; the note tells the agent to ask the +// user, then run explicit `jf setup` only after they confirm. +// +// Ownership: this is an APR/hooks-layer guard. Do NOT "fix" silent-repoint +// by changing jfrog-cli-artifactory / jfrog-cli-core `jf setup` writers — +// those commands intentionally overwrite when the user (or skill) asks. +// autoSetup is the unattended path that must refuse foreign hosts here. + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { createLogger } from "../../core/logger.mjs"; + +const log = createLogger("setup-conflict"); + +/** + * @param {string} [home] + * @returns {string} + */ +function resolveHome(home) { + if (home) return home; + // Match agents-config: Node `homedir()` (USERPROFILE on Windows). Preferring + // process.env.HOME on win32 breaks under MSYS/Git Bash path shapes. + if (process.platform === "win32") return homedir(); + return process.env.HOME || homedir(); +} + +/** + * Strip matching single/double quotes wrapping an npmrc value. + * @param {string} raw + * @returns {string} + */ +export function stripWrappedQuotes(raw) { + const s = String(raw ?? "").trim(); + if ( + (s.startsWith('"') && s.endsWith('"') && s.length >= 2) || + (s.startsWith("'") && s.endsWith("'") && s.length >= 2) + ) { + return s.slice(1, -1).trim(); + } + return s; +} + +/** + * Host (lowercase, no port) from a URL or registry string, or "". + * @param {string} raw + * @returns {string} + */ +export function registryHost(raw) { + if (!raw) return ""; + let s = stripWrappedQuotes(raw); + if (!s) return ""; + try { + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) { + s = `https://${s}`; + } + return new URL(s).hostname.toLowerCase(); + } catch { + return s + .replace(/^https?:\/\//i, "") + .split("/")[0] + .split(":")[0] + .toLowerCase(); + } +} + +/** + * Parse registry URLs from an npmrc body. Returns the default `registry=` + * value(s) when present; only falls back to `@scope:registry=` values when no + * default is set (a foreign scoped registry is not a `jf setup` conflict). + * @param {string} body + * @returns {string[]} registry URL values + */ +export function parseNpmrcRegistries(body) { + /** @type {string[]} */ + const def = []; + /** @type {string[]} */ + const scoped = []; + for (const line of String(body || "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) + continue; + const m = trimmed.match(/^(@[^\s:]+:)?registry\s*=\s*(.+)$/i); + if (m) (m[1] ? scoped : def).push(stripWrappedQuotes(m[2])); + } + // `jf setup` only repoints the DEFAULT registry, so a foreign default is a + // real conflict but a foreign `@scope:registry=` is not (setup won't touch + // it). Prefer the default; fall back to scoped only when no default is set. + return def.length ? def : scoped; +} + +/** + * Parse pip `index-url` / `extra-index-url` values from a pip.conf body. + * Mirrors paths used by jfrog-cli-artifactory setup (PIP_CONFIG_FILE or + * ~/.config/pip/pip.conf / %APPDATA%/pip/pip.ini). + * @param {string} body + * @returns {string[]} + */ +export function parsePipIndexUrls(body) { + const out = []; + for (const line of String(body || "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) + continue; + if (trimmed.startsWith("[")) continue; + const m = trimmed.match(/^(?:extra-)?index-url\s*=\s*(.+)$/i); + if (m) out.push(stripWrappedQuotes(m[1])); + } + return out; +} + +/** + * Candidate pip config file paths (first existing wins). + * @param {string} h home directory + * @returns {string[]} + */ +export function pipConfigFileCandidates(h) { + /** @type {string[]} */ + const out = []; + if (process.env.PIP_CONFIG_FILE) out.push(process.env.PIP_CONFIG_FILE); + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(h, "AppData", "Roaming"); + out.push(path.join(appData, "pip", "pip.ini")); + } + if (process.platform === "darwin") { + // pip reads the macOS per-user path ahead of the XDG fallback. + out.push(path.join(h, "Library", "Application Support", "pip", "pip.conf")); + } + out.push(path.join(h, ".config", "pip", "pip.conf")); + out.push(path.join(h, ".pip", "pip.conf")); + return out; +} + +/** + * @param {string} [home] + * @returns {string[]} + */ +function readPipIndexes(home) { + const h = resolveHome(home); + for (const file of pipConfigFileCandidates(h)) { + if (!existsSync(file)) continue; + try { + return parsePipIndexUrls(readFileSync(file, "utf8")); + } catch { + // try next + } + } + return []; +} + +/** + * Parse GOPROXY list from a go env file body (`key = value` lines). + * @param {string} body + * @returns {string[]} + */ +export function parseGoProxyList(body) { + for (const line of String(body || "").split(/\r?\n/)) { + const m = line.trim().match(/^GOPROXY\s*=\s*(.+)$/i); + if (!m) continue; + return m[1] + .split(",") + .map((s) => stripWrappedQuotes(s.trim())) + .filter( + (s) => s && s.toLowerCase() !== "direct" && s.toLowerCase() !== "off", + ); + } + return []; +} + +/** + * @param {string} targetUrl Artifactory base or package-type URL + * @param {string[]} existingRegistries + * @returns {{ conflict: boolean, existing?: string, targetHost?: string, existingHost?: string }} + */ +export function conflictAgainstTarget(targetUrl, existingRegistries) { + const targetHost = registryHost(targetUrl); + if (!targetHost) return { conflict: false }; + for (const existing of existingRegistries) { + const existingHost = registryHost(existing); + if (!existingHost) continue; + if (existingHost !== targetHost) { + return { conflict: true, existing, targetHost, existingHost }; + } + } + return { conflict: false, targetHost }; +} + +/** + * Prefer explicit registry URL lines; fall back to scoped-auth hosts only when + * no `registry=` / YAML registry is set (auth-only configs still conflict). + * Avoids leftover public `_authToken` lines false-conflicting when the live + * registry already points at Artifactory. + * @param {string[]} registryUrls + * @param {string[]} authHosts + * @returns {string[]} + */ +function preferRegistryUrls(registryUrls, authHosts) { + return registryUrls.length ? registryUrls : authHosts; +} + +/** + * Candidate npmrc paths (first existing wins). Honor NPM_CONFIG_USERCONFIG + * the same way pip honors PIP_CONFIG_FILE — live isolation redirects there. + * pnpm does NOT read this file for its own config (see + * {@link pnpmConfigFileCandidates}) — this is npm only. + * @param {string} h home directory + * @returns {string[]} + */ +export function npmrcFileCandidates(h) { + /** @type {string[]} */ + const out = []; + if (process.env.NPM_CONFIG_USERCONFIG) { + out.push(process.env.NPM_CONFIG_USERCONFIG); + } + out.push(path.join(h, ".npmrc")); + return out; +} + +/** + * Read npm user config registries (NPM_CONFIG_USERCONFIG or $HOME/.npmrc). + * Includes `registry=` lines and scoped-auth hosts (`//host/:_authToken=`) so + * auth-only npmrc (default registry = public npm) still conflicts. + * @param {string} [home] + * @returns {string[]} + */ +function readNpmRegistries(home) { + for (const file of npmrcFileCandidates(resolveHome(home))) { + if (!existsSync(file)) continue; + try { + const body = readFileSync(file, "utf8"); + return preferRegistryUrls( + parseNpmrcRegistries(body), + parseAuthIniHosts(body), + ); + } catch { + // try next + } + } + return []; +} + +/** + * Extract registry hosts from npmrc-style scoped-auth lines + * (`//hostname[:port]/path:_authToken=…`, `:_auth=…`, `:_password=…`). pnpm's + * `auth.ini` stores credentials this way without a `registry=` line, so a + * conflict can only be detected from the host in the auth key. + * @param {string} body + * @returns {string[]} hostnames (with port, if present — registryHost strips it) + */ +export function parseAuthIniHosts(body) { + const out = []; + for (const line of String(body || "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) + continue; + const m = trimmed.match( + /^\/\/([^/\s]+)\/\S*:_(?:authToken|auth|password)\b/i, + ); + if (m) out.push(m[1]); + } + return out; +} + +/** + * Extract registry URLs from a pnpm `config.yaml` body (`registry: https://…` + * or quoted). Nested `registries:` maps are out of scope. + * @param {string} body + * @returns {string[]} + */ +export function parsePnpmConfigYamlRegistries(body) { + const out = []; + for (const line of String(body || "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const m = trimmed.match(/^registry\s*:\s*(.+)$/i); + if (m) out.push(stripWrappedQuotes(m[1])); + } + return out; +} + +/** + * pnpm global config directories, in the order pnpm itself resolves them + * (first that exists is authoritative for pnpm; here we scan every one since + * auth vs. registry settings can be split across sibling files). + * @param {string} h home directory + * @returns {string[]} + */ +export function pnpmConfigDirCandidates(h) { + /** @type {string[]} */ + const out = []; + if (process.env.XDG_CONFIG_HOME) { + out.push(path.join(process.env.XDG_CONFIG_HOME, "pnpm")); + } + out.push(path.join(h, ".config", "pnpm")); + if (process.platform === "darwin") { + out.push(path.join(h, "Library", "Preferences", "pnpm")); + } + if (process.platform === "win32") { + const localAppData = + process.env.LOCALAPPDATA || path.join(h, "AppData", "Local"); + out.push(path.join(localAppData, "pnpm")); + } + return out; +} + +/** File names pnpm may keep global config/auth in, under a config dir. */ +const PNPM_CONFIG_FILE_NAMES = ["auth.ini", "rc", "config.yaml", ".npmrc"]; + +/** + * Candidate pnpm config file paths — every `{dir}/{name}` combination across + * {@link pnpmConfigDirCandidates} × {@link PNPM_CONFIG_FILE_NAMES}. Unlike + * {@link npmrcFileCandidates}, pnpm does NOT honor `NPM_CONFIG_USERCONFIG` + * for its own writes/reads — that env var is npm-only. + * @param {string} h home directory + * @returns {string[]} + */ +export function pnpmConfigFileCandidates(h) { + /** @type {string[]} */ + const out = []; + for (const dir of pnpmConfigDirCandidates(h)) { + for (const name of PNPM_CONFIG_FILE_NAMES) { + out.push(path.join(dir, name)); + } + } + return out; +} + +/** + * Read pnpm registries from every existing pnpm config file (auth.ini / rc / + * config.yaml / .npmrc under the pnpm config dir). Registries come from + * `registry=` lines (config/rc files) and scoped-auth hostnames (auth.ini). + * @param {string} [home] + * @returns {string[]} + */ +function readPnpmRegistries(home) { + const h = resolveHome(home); + /** @type {string[]} */ + const registryUrls = []; + /** @type {string[]} */ + const authHosts = []; + for (const file of pnpmConfigFileCandidates(h)) { + if (!existsSync(file)) continue; + try { + const body = readFileSync(file, "utf8"); + registryUrls.push(...parseNpmrcRegistries(body)); + authHosts.push(...parseAuthIniHosts(body)); + if ( + file.endsWith(`${path.sep}config.yaml`) || + file.endsWith("config.yaml") + ) { + registryUrls.push(...parsePnpmConfigYamlRegistries(body)); + } + } catch { + // try next file + } + } + return preferRegistryUrls(registryUrls, authHosts); +} + +/** + * Parse index / extra-index URLs from a uv.toml (or uv config) body. + * @param {string} body + * @returns {string[]} + */ +export function parseUvIndexUrls(body) { + const out = []; + // Bare `url = …` only counts as a registry inside an [[index]] / [[tool.uv.index]] + // table — elsewhere it could be an unrelated key. `index-url` / `extra-index-url` + // are top-level and always count. + let inIndexTable = false; + for (const line of String(body || "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (trimmed.startsWith("[")) { + inIndexTable = /^\[\[(?:tool\.uv\.)?index\]\]/i.test(trimmed); + continue; + } + const flat = trimmed.match(/^(?:extra-)?index-url\s*=\s*(.+)$/i); + if (flat) { + const raw = stripWrappedQuotes(flat[1]); + if (raw) out.push(raw); + continue; + } + if (inIndexTable) { + const urlLine = trimmed.match(/^url\s*=\s*(.+)$/i); + if (urlLine) { + const raw = stripWrappedQuotes(urlLine[1]); + if (raw) out.push(raw); + } + } + } + return out; +} + +/** + * Candidate uv config paths (first existing wins). + * @param {string} h home directory + * @returns {string[]} + */ +export function uvConfigFileCandidates(h) { + /** @type {string[]} */ + const out = []; + if (process.env.UV_CONFIG_FILE) out.push(process.env.UV_CONFIG_FILE); + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(h, "AppData", "Roaming"); + out.push(path.join(appData, "uv", "uv.toml")); + } + out.push(path.join(h, ".config", "uv", "uv.toml")); + return out; +} + +/** + * @param {string} [home] + * @returns {string[]} + */ +function readUvIndexes(home) { + const h = resolveHome(home); + for (const file of uvConfigFileCandidates(h)) { + if (!existsSync(file)) continue; + try { + return parseUvIndexUrls(readFileSync(file, "utf8")); + } catch { + // try next + } + } + return []; +} + +/** + * Candidate GOENV file paths for this platform (first existing wins). + * @param {string} h home directory + * @returns {string[]} + */ +export function goEnvFileCandidates(h) { + /** @type {string[]} */ + const out = []; + if (process.env.GOENV) out.push(process.env.GOENV); + if (process.platform === "darwin") { + out.push(path.join(h, "Library", "Application Support", "go", "env")); + } else if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(h, "AppData", "Roaming"); + out.push(path.join(appData, "go", "env")); + } + // Linux XDG + common fallback on all platforms + out.push(path.join(h, ".config", "go", "env")); + return out; +} + +/** + * Read GOPROXY from platform GOENV locations. + * @param {string} [home] + * @returns {string[]} + */ +function readGoProxies(home) { + const h = resolveHome(home); + for (const file of goEnvFileCandidates(h)) { + if (!existsSync(file)) continue; + try { + return parseGoProxyList(readFileSync(file, "utf8")); + } catch { + // try next + } + } + return []; +} + +/** + * ID used by `jf setup maven` for the Artifactory mirror in settings.xml + * (jfrog-cli-core `maven.ArtifactoryMirrorID`). Setup repoints this mirror + * in place — it does not add a second one. + */ +export const ARTIFACTORY_MAVEN_MIRROR_ID = "artifactory-mirror"; + +/** + * Strip XML comments so commented-out mirror blocks are not treated as active. + * @param {string} xml + * @returns {string} + */ +function stripXmlComments(xml) { + return String(xml || "").replace(//g, ""); +} + +/** + * Text content of a simple XML element body (plain text or one CDATA section). + * @param {string} inner + * @returns {string} + */ +function xmlElementText(inner) { + const s = String(inner || ""); + const cdata = s.match(//); + if (cdata) return cdata[1].trim(); + // Drop nested markup if present; mirror id/url are text nodes in practice. + return s.replace(/<[^>]+>/g, "").trim(); +} + +/** + * Extract the Artifactory mirror URL from a Maven settings.xml body. + * Only the mirror with id {@link ARTIFACTORY_MAVEN_MIRROR_ID} counts — + * that is what `jf setup maven` overwrites. + * @param {string} body + * @returns {string[]} zero or one URL + */ +export function parseMavenArtifactoryMirrorUrls(body) { + // Not a full XML DOM — strip comments + CDATA text extraction covers the + // failure modes that matter for conflict detection without a new dependency. + const xml = stripXmlComments(String(body || "")); + /** @type {string[]} */ + const out = []; + const mirrorRe = /]*>([\s\S]*?)<\/mirror>/gi; + let m; + while ((m = mirrorRe.exec(xml)) !== null) { + const block = m[1]; + const idMatch = block.match(/]*>([\s\S]*?)<\/id>/i); + if (!idMatch) continue; + if (xmlElementText(idMatch[1]) !== ARTIFACTORY_MAVEN_MIRROR_ID) continue; + const urlMatch = block.match(/]*>([\s\S]*?)<\/url>/i); + if (urlMatch) { + const url = stripWrappedQuotes(xmlElementText(urlMatch[1])); + if (url) out.push(url); + } + } + return out; +} + +/** + * Candidate Maven settings.xml paths (first existing wins). + * @param {string} h home directory + * @returns {string[]} + */ +export function mavenSettingsFileCandidates(h) { + return [path.join(h, ".m2", "settings.xml")]; +} + +/** + * @param {string} [home] + * @returns {string[]} + */ +function readMavenMirrorUrls(home) { + const h = resolveHome(home); + for (const file of mavenSettingsFileCandidates(h)) { + if (!existsSync(file)) continue; + try { + return parseMavenArtifactoryMirrorUrls(readFileSync(file, "utf8")); + } catch { + // try next + } + } + return []; +} + +/** + * @param {string} child + * @param {string} parent + * @returns {boolean} + */ +function pathIsUnderOrEqual(child, parent) { + const c = path.resolve(child); + const p = path.resolve(parent); + return c === p || c.startsWith(p + path.sep); +} + +/** + * True when `h` is the process home (production). Temp test homes must not + * inherit ambient GRADLE_USER_HOME / XDG_CONFIG_HOME outside the sandbox. + * @param {string} h + * @returns {boolean} + */ +function isProcessHome(h) { + return path.resolve(h) === path.resolve(resolveHome()); +} + +/** + * Fixed filename written by `jf setup gradle` under `$GRADLE_USER_HOME/init.d/`. + * (jfrog-cli-artifactory `gradle.InitScriptName`). + */ +export const ARTIFACTORY_GRADLE_INIT_SCRIPT = "jfrog.init.gradle"; + +/** + * Drop Groovy/Java-style comments so commented-out `def artifactoryUrl` + * lines are not treated as active (same idea as Maven XML comment stripping). + * @param {string} body + * @returns {string} + */ +function stripGroovyComments(body) { + let s = String(body || ""); + s = s.replace(/\/\*[\s\S]*?\*\//g, ""); + s = s.replace(/^\s*\/\/.*$/gm, ""); + return s; +} + +/** + * Parse `def artifactoryUrl = '…'` / `"…"` from a jfrog.init.gradle body. + * @param {string} body + * @returns {string[]} + */ +export function parseGradleArtifactoryUrls(body) { + /** @type {string[]} */ + const out = []; + for (const line of stripGroovyComments(body).split(/\r?\n/)) { + // Allow optional trailing `// …` after the closing quote. Do not strip + // bare `//` inside the line — that would corrupt `https://` in the URL. + const m = line.match( + /^\s*def\s+artifactoryUrl\s*=\s*(['"])(.+?)\1\s*(?:\/\/.*)?$/, + ); + if (m) { + const url = stripWrappedQuotes(m[2]); + if (url) out.push(url); + } + } + return out; +} + +/** + * Candidate paths for the JFrog Gradle init script (first existing wins). + * @param {string} h home directory + * @returns {string[]} + */ +export function gradleInitFileCandidates(h) { + /** @type {string[]} */ + const out = []; + const guh = process.env.GRADLE_USER_HOME; + if (guh && (pathIsUnderOrEqual(guh, h) || isProcessHome(h))) { + out.push(path.join(guh, "init.d", ARTIFACTORY_GRADLE_INIT_SCRIPT)); + } + const fallback = path.join( + h, + ".gradle", + "init.d", + ARTIFACTORY_GRADLE_INIT_SCRIPT, + ); + if (!out.includes(fallback)) out.push(fallback); + return out; +} + +/** + * @param {string} [home] + * @returns {string[]} + */ +function readGradleArtifactoryUrls(home) { + const h = resolveHome(home); + for (const file of gradleInitFileCandidates(h)) { + if (!existsSync(file)) continue; + try { + const urls = parseGradleArtifactoryUrls(readFileSync(file, "utf8")); + if (urls.length) return urls; + } catch { + // try next + } + } + return []; +} + +/** + * Source name used by `jf setup nuget` / `jf setup dotnet` + * (jfrog-cli-artifactory `dotnet.SourceName`). + */ +export const ARTIFACTORY_NUGET_SOURCE_NAME = "JFrogCli"; + +/** @param {string} s */ +function escapeRegExp(s) { + return String(s).replace(/[\\^$*+?.()|[\]{}]/g, "\\$&"); +} + +/** + * Extract the JFrogCli package source URL from a NuGet.Config body. + * @param {string} body + * @returns {string[]} + */ +export function parseNugetJFrogCliSourceUrls(body) { + // Same as Maven: ignore commented-out blocks. + const xml = stripXmlComments(String(body || "")); + /** @type {string[]} */ + const out = []; + const key = escapeRegExp(ARTIFACTORY_NUGET_SOURCE_NAME); + // (attribute order may vary) + const re = new RegExp( + `]*\\bkey\\s*=\\s*["']${key}["'][^>]*\\bvalue\\s*=\\s*["']([^"']+)["'][^>]*\\/?>`, + "gi", + ); + let m; + while ((m = re.exec(xml)) !== null) { + const url = stripWrappedQuotes(m[1]); + if (url) out.push(url); + } + // value before key + const re2 = new RegExp( + `]*\\bvalue\\s*=\\s*["']([^"']+)["'][^>]*\\bkey\\s*=\\s*["']${key}["'][^>]*\\/?>`, + "gi", + ); + while ((m = re2.exec(xml)) !== null) { + const url = stripWrappedQuotes(m[1]); + if (url) out.push(url); + } + return [...new Set(out)]; +} + +/** + * Candidate NuGet.Config paths (scan all that exist; first hit with JFrogCli wins via reader). + * @param {string} h home directory + * @returns {string[]} + */ +export function nugetConfigFileCandidates(h) { + /** @type {string[]} */ + const out = []; + // dotnet default + out.push(path.join(h, ".nuget", "NuGet", "NuGet.Config")); + // nuget / XDG-style — only ambient XDG when under sandbox home or real HOME + const xdg = process.env.XDG_CONFIG_HOME; + if (xdg && (pathIsUnderOrEqual(xdg, h) || isProcessHome(h))) { + out.push(path.join(xdg, "NuGet", "NuGet.Config")); + } + out.push(path.join(h, ".config", "NuGet", "NuGet.Config")); + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(h, "AppData", "Roaming"); + if (pathIsUnderOrEqual(appData, h) || isProcessHome(h)) { + out.push(path.join(appData, "NuGet", "NuGet.Config")); + } else { + out.push(path.join(h, "AppData", "Roaming", "NuGet", "NuGet.Config")); + } + } + return out; +} + +/** + * @param {string} [home] + * @returns {string[]} + */ +function readNugetJFrogCliUrls(home) { + const h = resolveHome(home); + for (const file of nugetConfigFileCandidates(h)) { + if (!existsSync(file)) continue; + try { + const urls = parseNugetJFrogCliSourceUrls(readFileSync(file, "utf8")); + if (urls.length) return urls; + } catch { + // try next + } + } + return []; +} + +/** + * Whether running `jf setup ` for `targetUrl` would repoint + * an existing user-level registry away from another host. + * + * Covered today: npm (`NPM_CONFIG_USERCONFIG` / `$HOME/.npmrc`), pnpm + * (own `auth.ini`/`rc`/`config.yaml` under the pnpm config dir **plus** + * npm's userconfig — some `jf setup pnpm` builds still write via + * `NPM_CONFIG_USERCONFIG`, so a foreign `.npmrc` must block pnpm too), + * pip/pipenv (`PIP_CONFIG_FILE` / platform pip.conf), uv + * (`UV_CONFIG_FILE` / uv.toml), go (platform GOENV paths), maven + * (`$HOME/.m2/settings.xml` mirror id `artifactory-mirror`), gradle + * (`$GRADLE_USER_HOME/init.d/jfrog.init.gradle`), nuget/dotnet + * (`JFrogCli` source in NuGet.Config). + * docker/podman/helm are additive logins (not default-registry overwrite) — + * left uncovered until product treats multi-host auth as a conflict. + * + * @param {string} packageManager + * @param {string} targetUrl platform or package URL whose host is the target + * @param {{ home?: string }} [opts] + * @returns {{ conflict: boolean, existing?: string, targetHost?: string, existingHost?: string }} + */ +export function detectSetupConflict(packageManager, targetUrl, opts = {}) { + const pm = String(packageManager || "").toLowerCase(); + let existing = []; + if (pm === "npm") { + existing = readNpmRegistries(opts.home); + } else if (pm === "pnpm") { + // Union: native pnpm config + npm userconfig. Native-only misses + // CLI builds that still configure pnpm by rewriting NPM_CONFIG_USERCONFIG. + existing = [ + ...readPnpmRegistries(opts.home), + ...readNpmRegistries(opts.home), + ]; + } else if (pm === "pip" || pm === "pipenv") { + existing = readPipIndexes(opts.home); + } else if (pm === "uv") { + existing = readUvIndexes(opts.home); + } else if (pm === "go") { + existing = readGoProxies(opts.home); + } else if (pm === "maven" || pm === "mvn") { + existing = readMavenMirrorUrls(opts.home); + } else if (pm === "gradle") { + existing = readGradleArtifactoryUrls(opts.home); + } else if (pm === "nuget" || pm === "dotnet") { + existing = readNugetJFrogCliUrls(opts.home); + } else { + // docker / podman / helm: additive registry login — not a silent default rewrite + return { conflict: false }; + } + + if (!existing.length) return { conflict: false }; + + const result = conflictAgainstTarget(targetUrl, existing); + if (result.conflict) { + log.info("eager setup conflict: existing registry points elsewhere", { + packageManager: pm, + existingHost: result.existingHost, + targetHost: result.targetHost, + }); + } + return result; +} diff --git a/modules/package-resolution/scripts/verify-repo.mjs b/modules/package-resolution/scripts/verify-repo.mjs new file mode 100644 index 0000000..c96b729 --- /dev/null +++ b/modules/package-resolution/scripts/verify-repo.mjs @@ -0,0 +1,196 @@ +// 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 { skillsProductUserAgent } from "../../core/jf-user-agent.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", + "User-Agent": skillsProductUserAgent(), + }, + 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/modules/package-resolution/scripts/workspace-config.mjs b/modules/package-resolution/scripts/workspace-config.mjs new file mode 100644 index 0000000..177e443 --- /dev/null +++ b/modules/package-resolution/scripts/workspace-config.mjs @@ -0,0 +1,80 @@ +// Workspace-local repo overrides — `.jfrog/local/package-resolution.json` +// Schema: `{ "repositories": { "": "", ... } }` only. +// +// Multi-root: first root (in harness order) that has the file wins. + +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { isSafeRepoKey } from "./repo-types.mjs"; + +export const WORKSPACE_CONFIG_FILE = "package-resolution.json"; + +/** + * First workspace root that has `.jfrog/local/package-resolution.json`. + * + * @param {string[]} workspaceRoots + * @returns {{ root: string, configFile: string } | null} + */ +export function pickWorkspaceConfigRoot(workspaceRoots) { + if (!workspaceRoots?.length) return null; + for (const root of workspaceRoots) { + if (typeof root !== "string" || !root) continue; + const configFile = path.join( + root, + ".jfrog", + "local", + WORKSPACE_CONFIG_FILE, + ); + if (existsSync(configFile)) { + return { root, configFile }; + } + } + return null; +} + +function normalizeWorkspaceConfig(data) { + if (!data?.repositories || typeof data.repositories !== "object") return null; + const repositories = {}; + for (const [type, repoKey] of Object.entries(data.repositories)) { + if (isSafeRepoKey(repoKey)) repositories[type] = repoKey; + } + if (!Object.keys(repositories).length) return null; + return { repositories }; +} + +/** + * Read + validate the workspace config, reporting *why* it was rejected so + * callers can surface actionable diagnostics (a silently-ignored typo in this + * file is otherwise impossible to notice). + * + * @param {{ root: string, configFile: string }} pick + * @returns {Promise< + * | { status: "ok", config: { repositories: Record } } + * | { status: "absent" } + * | { status: "unreadable", error: Error } + * | { status: "invalid", error: Error } + * | { status: "empty" } + * >} + */ +export async function loadWorkspaceConfig(pick) { + if (!pick?.configFile) return { status: "absent" }; + + let raw; + try { + raw = await readFile(pick.configFile, "utf8"); + } catch (err) { + return { status: "unreadable", error: err }; + } + + let data; + try { + data = JSON.parse(raw); + } catch (err) { + return { status: "invalid", error: err }; + } + + const config = normalizeWorkspaceConfig(data); + if (!config) return { status: "empty" }; + return { status: "ok", config }; +} diff --git a/modules/package-resolution/templates/package-resolution-unconfigured.md b/modules/package-resolution/templates/package-resolution-unconfigured.md new file mode 100644 index 0000000..83ddf57 --- /dev/null +++ b/modules/package-resolution/templates/package-resolution-unconfigured.md @@ -0,0 +1,41 @@ +# Package Resolution — JFrog Artifactory routing is NOT READY + +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 — 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 + +1. **`jfrog-setup-package-managers`** skill — **Step 0** for this state. +2. Base **`jfrog`** skill — `references/jfrog-login-flow.md` for server login. + +## What to do instead + +{{CAUSE_REMEDIATION}} + +{{JFROG_PLATFORM_URL_HINT}} + +{{CAUSE_CHECKLIST}} + +## After setup — load the routing policy + +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 policy — it **supersedes this +"NOT READY" notice** and prints resolved Artifactory URLs and hard rules. Then +re-issue the install using those URLs. + +If the command prints nothing, routing is off +(`packageResolution.enabled` is not `true`) — report that and let the user decide. diff --git a/modules/package-resolution/templates/package-resolution.md b/modules/package-resolution/templates/package-resolution.md new file mode 100644 index 0000000..fc0cffd --- /dev/null +++ b/modules/package-resolution/templates/package-resolution.md @@ -0,0 +1,50 @@ +# Package Resolution — Artifactory First + +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 — follow the **Decision order** below. +{{UNRESOLVED_BLOCK}} +{{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** — the type is **BLOCKED** above, or its row below is `` or `NOT ROUTED (...)` → do **not** install; invoke the setup skill for that type. Never invent a URL or use a public registry. +2. **Zero-touch handled** — only applies when a **Package manager setup** status line appears above; when present, it 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}} + +Unresolved rows → Decision step 1 (setup skill; no public registries). + +## Rewrite templates + +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 (governed types only) + +{{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** — 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}} diff --git a/package.json b/package.json index 9cfd553..91a1950 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-plugin", - "version": "0.1.9", + "version": "0.1.10", "private": true, "type": "module", "description": "JFrog skills and MCP server plugin for OpenAI Codex.", diff --git a/scripts/validate.test.mjs b/scripts/validate.test.mjs index 4f92010..e8f852a 100644 --- a/scripts/validate.test.mjs +++ b/scripts/validate.test.mjs @@ -1,9 +1,10 @@ // (c) JFrog Ltd. (2026) import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { extractFrontmatter, parseField, @@ -14,6 +15,8 @@ import { validateMcp, } from './validate.mjs'; +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + function writeSkill(root, dir, body) { mkdirSync(join(root, dir), { recursive: true }); writeFileSync(join(root, dir, 'SKILL.md'), body); @@ -83,6 +86,23 @@ test('validateMcp accepts direct and wrapped server maps, flags servers without assert.ok(validateMcp({ jfrog: {} }).some((e) => e.includes('url') && e.includes('command'))); }); +test('APR SessionStart hook is the Codex plugin command with a 4000-token context limit', () => { + assert.equal(existsSync(join(repoRoot, 'plugin.json')), false); + const hooks = JSON.parse(readFileSync(join(repoRoot, 'hooks/hooks.json'), 'utf8')); + const cmd = hooks.hooks.SessionStart[0].hooks[0]; + assert.equal( + cmd.command, + 'node "${PLUGIN_ROOT}/modules/codex-session-start.mjs" package-resolution', + ); + assert.equal(cmd.type, 'command'); + assert.equal(cmd.timeout, 7); + assert.equal(cmd.additionalContextLimit, 4000); + const manifest = JSON.parse(readFileSync(join(repoRoot, '.codex-plugin/plugin.json'), 'utf8')); + assert.equal(manifest.hooks, './hooks/hooks.json'); + assert.ok(manifest.keywords.includes('package-resolution')); + assert.ok(existsSync(join(repoRoot, 'modules/codex-session-start.mjs'))); +}); + test('validateMarketplace requires a local source with a ./ path', () => { assert.deepEqual( validateMarketplace({ name: 'm', plugins: [{ name: 'jfrog', source: { source: 'local', path: './' } }] }),