From e03d4187650fdd0452c2396b365917bd837607f2 Mon Sep 17 00:00:00 2001 From: "jfrog-skills-release-bot[bot]" <276080306+jfrog-skills-release-bot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:18:47 +0000 Subject: [PATCH 1/6] [patch] chore: sync modules to v0.11.0 --- .cursor-plugin/marketplace.json | 2 +- .github/scripts/sync-modules-vendor.json | 2 +- plugins/jfrog/.cursor-plugin/plugin.json | 2 +- .../assets/agents-conf-fingerprints.json | 30 + .../modules/assets/agents-default-conf.json | 14 +- .../jfrog/modules/claude-session-start.mjs | 12 +- .../jfrog/modules/copilot-session-start.mjs | 90 ++ .../jfrog/modules/core/agent-guard-check.mjs | 334 +++++ plugins/jfrog/modules/core/agents-config.mjs | 238 +++- plugins/jfrog/modules/core/entry.mjs | 36 + plugins/jfrog/modules/core/io.mjs | 37 +- plugins/jfrog/modules/core/jf-identity.mjs | 396 +++++- plugins/jfrog/modules/core/jf-user-agent.mjs | 104 ++ plugins/jfrog/modules/core/logger.mjs | 18 +- .../jfrog/modules/core/rewrite-mcp-json.mjs | 1147 +++++++++++++++++ plugins/jfrog/modules/core/run-capability.mjs | 20 +- .../modules/core/scaffold-fingerprint.mjs | 96 ++ .../jfrog/modules/cursor-session-start.mjs | 12 +- .../onboarding/package-resolution-nudge.md | 69 + ...package-resolution-onboarding-procedure.md | 169 +++ .../scripts/apr-heartbeat.mjs | 349 +++++ .../package-resolution/scripts/configure.mjs | 290 +++++ .../scripts/eager-setup-receipt.mjs | 7 +- .../scripts/eager-setup.mjs | 179 ++- .../scripts/feature-flag.mjs | 35 +- .../package-resolution/scripts/index.mjs | 88 ++ .../scripts/onboarding-decline-cache.mjs | 215 +++ .../package-resolution/scripts/onboarding.mjs | 231 ++++ .../scripts/render-instruction.mjs | 107 +- .../package-resolution/scripts/repo-types.mjs | 6 + .../package-resolution/scripts/resolver.mjs | 271 +++- .../scripts/setup-conflict.mjs | 807 ++++++++++++ .../scripts/sync-onboarding-rule.mjs | 267 ++++ .../scripts/verify-repo.mjs | 194 +++ .../scripts/workspace-config.mjs | 3 +- .../package-resolution-unconfigured.md | 36 +- .../templates/package-resolution.md | 57 +- 37 files changed, 5717 insertions(+), 253 deletions(-) create mode 100644 plugins/jfrog/modules/assets/agents-conf-fingerprints.json create mode 100644 plugins/jfrog/modules/copilot-session-start.mjs create mode 100644 plugins/jfrog/modules/core/agent-guard-check.mjs create mode 100644 plugins/jfrog/modules/core/entry.mjs create mode 100644 plugins/jfrog/modules/core/jf-user-agent.mjs create mode 100644 plugins/jfrog/modules/core/rewrite-mcp-json.mjs create mode 100644 plugins/jfrog/modules/core/scaffold-fingerprint.mjs create mode 100644 plugins/jfrog/modules/package-resolution/onboarding/package-resolution-nudge.md create mode 100644 plugins/jfrog/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md create mode 100644 plugins/jfrog/modules/package-resolution/scripts/apr-heartbeat.mjs create mode 100644 plugins/jfrog/modules/package-resolution/scripts/configure.mjs create mode 100644 plugins/jfrog/modules/package-resolution/scripts/onboarding-decline-cache.mjs create mode 100644 plugins/jfrog/modules/package-resolution/scripts/onboarding.mjs create mode 100644 plugins/jfrog/modules/package-resolution/scripts/setup-conflict.mjs create mode 100644 plugins/jfrog/modules/package-resolution/scripts/sync-onboarding-rule.mjs create mode 100644 plugins/jfrog/modules/package-resolution/scripts/verify-repo.mjs diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index d479a13..92357cc 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "JFrog Platform plugins for Cursor", - "version": "0.5.14", + "version": "0.5.15", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 4ca704b..fa0b57e 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -1,6 +1,6 @@ { "repo": "JFROG/jfrog-agent-hooks", - "pin": "jfrog-agent-hooks/v0.8.1", + "pin": "jfrog-agent-hooks/v0.11.0", "paths": [ "modules" ] diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index 04250c5..f7cf10c 100644 --- a/plugins/jfrog/.cursor-plugin/plugin.json +++ b/plugins/jfrog/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "displayName": "JFrog Platform", - "version": "0.5.14", + "version": "0.5.15", "description": "JFrog Platform integration with MCP, security skills, Agent Package Resolution, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.", "author": { "name": "JFrog", diff --git a/plugins/jfrog/modules/assets/agents-conf-fingerprints.json b/plugins/jfrog/modules/assets/agents-conf-fingerprints.json new file mode 100644 index 0000000..59a9182 --- /dev/null +++ b/plugins/jfrog/modules/assets/agents-conf-fingerprints.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "fingerprints": [ + { + "id": "v0-placeholders-no-onboardingPrompt", + "sha256": "452b737ede2af5da3ea660cb0a2226d422b5624fa1684bc883279422c0728421", + "note": "Legacy template with example repo keys, before onboardingPrompt" + }, + { + "id": "v1-placeholders-onboardingPrompt-auto", + "sha256": "b19251b4671db244a8050885bcbaf5f217f0e4eecfec34c0338264b08fa7c871", + "note": "Legacy template with example repo keys + onboardingPrompt: auto" + }, + { + "id": "v2-empty-defaultGlobalRepos", + "sha256": "5a104c83c4cb67f2cb01d71ad0044a438f9125bab868ef76e24bd7be7828b82b", + "note": "Empty defaultGlobalRepos after #84 (no onboardingPrompt)" + }, + { + "id": "v3-empty-onboardingPrompt-auto", + "sha256": "8b68d55af89e2dadf4ff0c3ae0784b70051c24d1fb75e3ea6df8ba3044c5cefa", + "note": "Legacy template: enabled false + empty defaultGlobalRepos + onboardingPrompt: auto" + }, + { + "id": "v4-enabled-onboardingPrompt-auto", + "sha256": "f0481d915f1f7f2a1e7d88ab23ce7b9430d3e44e41aaabb4d4d13e2b40963ae2", + "note": "Current shipped template: enabled true + empty defaultGlobalRepos + onboardingPrompt: auto" + } + ] +} diff --git a/plugins/jfrog/modules/assets/agents-default-conf.json b/plugins/jfrog/modules/assets/agents-default-conf.json index 3d13fce..35ceff5 100644 --- a/plugins/jfrog/modules/assets/agents-default-conf.json +++ b/plugins/jfrog/modules/assets/agents-default-conf.json @@ -1,19 +1,11 @@ { "logLevel": "info", "packageResolution": { - "enabled": false, + "enabled": true, "verifyRepos": true, "cacheTtlDays": 7, - "defaultGlobalRepos": { - "npm": "npm-virtual", - "pypi": "pypi-virtual", - "maven": "maven-virtual", - "gradle": "gradle-virtual", - "go": "go-virtual", - "docker": "docker-virtual", - "helm": "helm-virtual", - "nuget": "nuget-virtual" - }, + "onboardingPrompt": "auto", + "defaultGlobalRepos": {}, "autoSetup": [] } } diff --git a/plugins/jfrog/modules/claude-session-start.mjs b/plugins/jfrog/modules/claude-session-start.mjs index d7e688b..9a0f81b 100644 --- a/plugins/jfrog/modules/claude-session-start.mjs +++ b/plugins/jfrog/modules/claude-session-start.mjs @@ -9,8 +9,16 @@ import process from "node:process"; import { runCapability } from "./core/run-capability.mjs"; -import { ensureAgentsConfigScaffold, agentsConfigLoadWarnings } from "./core/agents-config.mjs"; -import { readStdin, parseSessionId, detectHarness, parseWorkspaceRoots } from "./core/io.mjs"; +import { + ensureAgentsConfigScaffold, + agentsConfigLoadWarnings, +} from "./core/agents-config.mjs"; +import { + readStdin, + parseSessionId, + detectHarness, + parseWorkspaceRoots, +} from "./core/io.mjs"; import { setLogContext, createLogger } from "./core/logger.mjs"; const HARNESS_ID = "claude_code"; diff --git a/plugins/jfrog/modules/copilot-session-start.mjs b/plugins/jfrog/modules/copilot-session-start.mjs new file mode 100644 index 0000000..9d0c275 --- /dev/null +++ b/plugins/jfrog/modules/copilot-session-start.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// GitHub Copilot Chat SessionStart hook runner (installed via the VS Code +// Copilot plugin — see jfrog/vscode-plugin). +// +// Usage: node copilot-session-start.mjs +// Example: node copilot-session-start.mjs package-resolution +// +// stdout: JSON with hookSpecificOutput.additionalContext. "{}" is a no-op. + +import process from "node:process"; + +import { runCapability } from "./core/run-capability.mjs"; +import { + ensureAgentsConfigScaffold, + agentsConfigLoadWarnings, +} from "./core/agents-config.mjs"; +import { + readStdin, + parseSessionId, + detectHarness, + parseWorkspaceRoots, +} from "./core/io.mjs"; +import { setLogContext, createLogger } from "./core/logger.mjs"; + +const HARNESS_ID = "copilot"; +const log = createLogger("session-start"); + +/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */ +function formatSessionStartStdout(text) { + if (!text?.trim()) return null; + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: text, + }, + }); +} + +function writeStdout(payload) { + if (payload === null) { + writeNoOp(); + return; + } + process.stdout.write(payload); +} + +function writeNoOp() { + process.stdout.write("{}"); +} + +async function main() { + const capability = process.argv[2]; + if (!capability) { + writeNoOp(); + return; + } + + const startedAtMs = Date.now(); + const stdinRaw = await readStdin(); + const harness = detectHarness(stdinRaw); + if (harness && harness !== HARNESS_ID) { + setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); + log.warn("harness mismatch; wrong adapter invoked", { + expected: HARNESS_ID, + detected: harness, + adapter: "copilot-session-start", + }); + writeNoOp(); + return; + } + const sessionId = parseSessionId(stdinRaw); + const workspaceRoots = parseWorkspaceRoots(stdinRaw); + setLogContext({ ide: HARNESS_ID, sessionId }); + ensureAgentsConfigScaffold(); + for (const w of agentsConfigLoadWarnings()) { + log.warn(w.message, { path: w.path }); + } + const text = await runCapability(capability, { + ide: HARNESS_ID, + sessionId, + workspaceRoots, + startedAtMs, + }); + writeStdout(formatSessionStartStdout(text)); +} + +main().catch(() => { + writeNoOp(); + process.exit(0); +}); diff --git a/plugins/jfrog/modules/core/agent-guard-check.mjs b/plugins/jfrog/modules/core/agent-guard-check.mjs new file mode 100644 index 0000000..8789667 --- /dev/null +++ b/plugins/jfrog/modules/core/agent-guard-check.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node +// JFrog Agent Guard activation check +// +// Silent gate for session hooks. Determines whether Agent Guard is enabled +// for the current environment. +// +// Contract (key off `code`, not `reason` text): +// - code 0 -> Agent Guard ENABLED (caller may proceed) +// - code 2 -> reachable but the platform has the MCP registry DISABLED +// - code 1 -> DISABLED for any other reason: no credentials, timeout, +// network/DNS error (caller must silently abort) +// +// Set JF_AGENT_GUARD_DEBUG=true for verbose tracing on stderr. +// Library callers use runAgentGuardCheck(); CLI entry calls process.exit. + +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +import { isMainEntry } from "./entry.mjs"; + +export const SETTINGS_PATH = + "/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled"; +export const REQUEST_TIMEOUT_MS = 5000; + +export const EXIT_ENABLED = 0; +export const EXIT_DISABLED = 1; +export const EXIT_REGISTRY_DISABLED = 2; + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {string} newName + * @param {string} [oldName] + * @returns {string | undefined} + */ +function envLookup(env, newName, oldName) { + const raw = env[newName] ?? (oldName ? env[oldName] : undefined); + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed || undefined; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {(message: string) => void} [debug] + */ +function makeDebug(env, debug) { + if (typeof debug === "function") return debug; + const enabled = env.JF_AGENT_GUARD_DEBUG === "true"; + return (message) => { + if (enabled) console.error(`[jfrog-agent-guard] ${message}`); + }; +} + +/** + * Resolve credentials from Path A (environment variables) or Path B + * (JFrog CLI configuration). + * + * Intentionally distinct from `jf-identity.mjs`: + * - package-resolution identity is always `jf config` and may use Basic auth; + * - Agent Guard's settings probe needs a Bearer access token, and mirrors the + * AG CLI by preferring JFROG_URL/JF_URL + access token when set. + * - When `serverId` is set: that jf server first, then env, never the default + * CLI server. Without `serverId`: env first, then default `jf config export`. + * Do not reuse getPlatformIdentity() here without preserving that contract. + * + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} [opts] + * @returns {{ baseUrl: string, token: string, source: string } | null} + */ +export function resolveAgentGuardCredentials(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + const explicitServerId = opts.serverId?.trim() || undefined; + const execFn = opts.execFileSyncFn ?? execFileSync; + + if (explicitServerId) { + const fromCli = resolveFromCliConfig({ + serverId: explicitServerId, + execFileSyncFn: execFn, + debug, + }); + if (fromCli) return fromCli; + debug( + "Explicit server ID did not resolve via jf config; falling back to env credentials.", + ); + } + + const envUrl = envLookup(env, "JFROG_URL", "JF_URL"); + const envToken = envLookup(env, "JFROG_ACCESS_TOKEN", "JF_ACCESS_TOKEN"); + if (envUrl && envToken) { + debug("Using credentials from environment variables (Path A)."); + return { + baseUrl: envUrl, + token: envToken, + source: "environment variables", + }; + } + debug( + "Environment credentials incomplete; trying JFrog CLI config (Path B).", + ); + + if (explicitServerId) return null; + return resolveFromCliConfig({ + serverId: undefined, + execFileSyncFn: execFn, + debug, + }); +} + +/** + * @param {{ + * serverId?: string, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} opts + */ +function resolveFromCliConfig(opts) { + const debug = opts.debug ?? (() => {}); + const execFn = opts.execFileSyncFn ?? execFileSync; + const exportArgs = opts.serverId + ? ["config", "export", opts.serverId] + : ["config", "export"]; + let exported; + try { + exported = execFn("jf", exportArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }).trim(); + } catch (error) { + debug( + `'jf config export' failed (jf not on PATH or no server configured): ${error?.message}`, + ); + return null; + } + + let cfg; + try { + cfg = JSON.parse(Buffer.from(exported, "base64").toString("utf8")); + } catch (error) { + debug(`Could not decode the jf config export token: ${error?.message}`); + return null; + } + + const baseUrl = cfg?.url; + const token = cfg?.accessToken; + if (!baseUrl) { + debug("Exported JFrog CLI config has no platform URL."); + return null; + } + if (!token) { + debug( + "Exported JFrog CLI config has no access token (bearer auth needed).", + ); + return null; + } + + const id = cfg?.serverId ?? "default"; + return { + baseUrl, + token, + source: `JF CLI config (server '${id}')`, + }; +} + +/** + * @param {string} baseUrl + * @param {string} token + * @param {{ + * fetchFn?: typeof fetch, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + */ +export async function isGatewayPluginEnabled(baseUrl, token, opts = {}) { + const debug = opts.debug ?? (() => {}); + const fetchFn = opts.fetchFn ?? fetch; + const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS; + + const root = baseUrl.replace(/\/+$/, "").replace(/\/artifactory$/, ""); + const url = root + SETTINGS_PATH; + debug(`Fetching gateway plugin setting from ${url}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchFn(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + }); + if (!response.ok) { + debug(`Settings request returned HTTP ${response.status}.`); + return { + ok: false, + reason: `settings endpoint returned HTTP ${response.status}`, + }; + } + const data = await response.json(); + const unwrap = (v) => (v !== null && typeof v === "object" ? v?.value : v); + const container = data?.settings ?? data; + const named = + container?.mcpGatewayPluginEnabled ?? + container?.mcp_gateway_plugin_enabled; + const value = + typeof data === "boolean" + ? data + : named !== undefined + ? unwrap(named) + : unwrap(container); + debug(`Settings response indicates gateway plugin enabled=${value}.`); + if (value === true) return { ok: true }; + if (value === false) { + return { + ok: false, + registryOff: true, + reason: "mcp gateway plugin setting returned false", + }; + } + return { + ok: false, + reason: "settings endpoint returned an invalid gateway-plugin setting", + }; + } catch (error) { + const reason = + error?.name === "AbortError" + ? "timeout" + : (error?.message ?? "unknown error"); + debug(`Settings request failed: ${reason}`); + return { + ok: false, + reason: `settings endpoint unreachable (${reason})`, + }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Run the Agent Guard activation check without exiting the process. + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * fetchFn?: typeof fetch, + * execFileSyncFn?: typeof execFileSync, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + * @returns {Promise<{ code: number, reason: string }>} + */ +export async function runAgentGuardCheck(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + + try { + const forceDisabled = + envLookup(env, "_JF_AGENT_GUARD_FORCE_DISABLE") === "true"; + const forceEnabled = + envLookup(env, "JF_AGENT_GUARD_FORCE_ENABLE") === "true"; + if (forceDisabled) { + return { + code: EXIT_DISABLED, + reason: "Disabled: forced via _JF_AGENT_GUARD_FORCE_DISABLE", + }; + } + if (forceEnabled) { + return { + code: EXIT_ENABLED, + reason: "Enabled: forced via JF_AGENT_GUARD_FORCE_ENABLE", + }; + } + + const creds = resolveAgentGuardCredentials({ + serverId: opts.serverId, + env, + execFileSyncFn: opts.execFileSyncFn, + debug, + }); + if (!creds) { + return { + code: EXIT_DISABLED, + reason: + "Disabled: JFROG_URL/JF_URL + access token not set and no default JF CLI config found", + }; + } + + const result = await isGatewayPluginEnabled(creds.baseUrl, creds.token, { + fetchFn: opts.fetchFn, + timeoutMs: opts.timeoutMs, + debug, + }); + if (result.ok) { + return { + code: EXIT_ENABLED, + reason: `Enabled: via ${creds.source}`, + }; + } + if (result.registryOff) { + return { + code: EXIT_REGISTRY_DISABLED, + reason: `RegistryDisabled: ${result.reason}`, + }; + } + return { + code: EXIT_DISABLED, + reason: `Disabled: ${result.reason}`, + }; + } catch (error) { + debug(`Unexpected error: ${error?.stack ?? error?.message ?? error}`); + return { code: EXIT_DISABLED, reason: "Disabled: unexpected error" }; + } +} + +async function main() { + const result = await runAgentGuardCheck({ + serverId: process.argv[2], + }); + process.stdout.write(`${result.reason}\n`); + process.exit(result.code); +} + +if (isMainEntry(import.meta.url)) { + main().catch((error) => { + console.error(`[jfrog-agent-guard] Unexpected error: ${error?.message}`); + process.exit(EXIT_DISABLED); + }); +} diff --git a/plugins/jfrog/modules/core/agents-config.mjs b/plugins/jfrog/modules/core/agents-config.mjs index 64a43df..6e55a85 100644 --- a/plugins/jfrog/modules/core/agents-config.mjs +++ b/plugins/jfrog/modules/core/agents-config.mjs @@ -4,15 +4,20 @@ // before capabilities run so first-time installs get a writable config file. import { - copyFileSync, + closeSync, existsSync, mkdirSync, + openSync, readFileSync, + renameSync, statSync, + unlinkSync, + writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isSafeRepoKey } from "../package-resolution/scripts/repo-types.mjs"; /** modules bundle root (parent of core/ and assets/). */ const PLUGIN_ROOT = path.resolve( @@ -28,8 +33,12 @@ const TEMPLATE_PATH = path.join( const DEFAULT_LOG_LEVEL = "info"; const DEFAULT_CACHE_TTL_DAYS = 7; +const AGENTS_CONFIG_LOCK_STALE_MS = 30_000; +const AGENTS_CONFIG_LOCK_WAIT_MS = 1_000; +const AGENTS_CONFIG_LOCK_POLL_MS = 25; let memoizedRaw = undefined; let memoizedForPath = null; +let memoizedMtimeMs = undefined; /** @type {{ source: 'missing' | 'user' | 'template', parseFailed: boolean, path: string }} */ let loadMeta = { source: "missing", parseFailed: false, path: "" }; @@ -37,29 +46,135 @@ 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) { + try { + const raw = readFileSync(agentsConfigLockPath(), "utf8"); + const ts = Number(raw.split("\n")[1]); + if (!Number.isFinite(ts) || nowMs - ts > AGENTS_CONFIG_LOCK_STALE_MS) { + unlinkSync(agentsConfigLockPath()); + return true; + } + } catch { + // ignore + } + return false; +} + +function acquireAgentsConfigLock(nowMs = Date.now()) { + try { + tryAgentsConfigLock(); + return true; + } catch { + if (!reclaimStaleAgentsConfigLock(nowMs)) return false; + try { + tryAgentsConfigLock(); + return true; + } catch { + return false; + } + } +} + +/** + * Serialize read-merge-rename of agents-conf.json across processes. + * Fails closed when the lock cannot be acquired — never silently races an + * unlocked RMW (Consent Enable / dismiss / SessionStart can overlap). + */ +function withAgentsConfigLock(fn) { + const deadline = Date.now() + AGENTS_CONFIG_LOCK_WAIT_MS; + let locked = acquireAgentsConfigLock(); + while (!locked && Date.now() < deadline) { + sleepSync(AGENTS_CONFIG_LOCK_POLL_MS); + locked = acquireAgentsConfigLock(Date.now()); + } + if (!locked) { + throw new Error( + "agents-conf.lock: could not acquire lock within wait budget", + ); + } + try { + return fn(); + } finally { + releaseAgentsConfigLock(); + } +} + function resetLoadMeta(configPath) { loadMeta = { source: "missing", parseFailed: false, path: configPath }; } /** - * Copy the shipped template to ~/.jfrog/agents-conf.json when missing. - * Never overwrites an existing file. + * Copy the shipped template when missing. Caller must hold agents-conf.lock + * (or use {@link ensureAgentsConfigScaffold}). Uses exclusive create so a + * late scaffold cannot clobber a concurrent patch that already created the file. */ -export function ensureAgentsConfigScaffold() { +function ensureAgentsConfigScaffoldUnlocked() { const configPath = agentsConfigPath(); if (existsSync(configPath)) return { created: false, path: configPath }; try { mkdirSync(path.dirname(configPath), { recursive: true }); - copyFileSync(TEMPLATE_PATH, configPath); + const fd = openSync(configPath, "wx"); + try { + writeFileSync(fd, readFileSync(TEMPLATE_PATH)); + } finally { + closeSync(fd); + } memoizedRaw = undefined; + memoizedForPath = null; + memoizedMtimeMs = undefined; return { created: true, path: configPath }; } catch { + // Another writer won the create race — treat as already present. + if (existsSync(configPath)) { + return { created: false, path: configPath }; + } return { created: false, path: configPath }; } } +/** + * Copy the shipped template to ~/.jfrog/agents-conf.json when missing. + * Never overwrites an existing file. Serialized with mergeAgentsConfigPatch. + */ +export function ensureAgentsConfigScaffold() { + return withAgentsConfigLock(() => ensureAgentsConfigScaffoldUnlocked()); +} + export { agentsConfigPath }; +/** Drop the in-process config memo (tests / direct writers that skip mergeAgentsConfigPatch). */ +export function invalidateAgentsConfigCache() { + memoizedRaw = undefined; + memoizedForPath = null; + memoizedMtimeMs = undefined; +} + /** @returns {number | null} mtime in ms, or null when the file is absent */ export function getAgentsConfigMtimeMs() { try { @@ -80,9 +195,15 @@ function parseAgentsJson(raw) { function readAgentsConfigRaw() { const configPath = agentsConfigPath(); - if (memoizedForPath !== configPath) { + const mtimeMs = getAgentsConfigMtimeMs(); + if ( + memoizedForPath !== configPath || + memoizedMtimeMs !== mtimeMs || + memoizedRaw === undefined + ) { memoizedRaw = undefined; memoizedForPath = configPath; + memoizedMtimeMs = mtimeMs; resetLoadMeta(configPath); } if (memoizedRaw !== undefined) return memoizedRaw; @@ -163,20 +284,117 @@ export function loadAgentsConfig() { enabled: pr.enabled === true, verifyRepos: pr.verifyRepos !== false, cacheTtlDays: normalizeCacheTtlDays(pr.cacheTtlDays), + onboardingPrompt: normalizeOnboardingPrompt(pr.onboardingPrompt), defaultGlobalRepos, autoSetup: normalizeAutoSetup(pr.autoSetup), }, }; } +/** + * Raw onboardingPrompt field: "auto" | "off" | "absent" (legacy / missing). + * Not normalized to auto — callers distinguish fingerprint fallback. + */ +export function getOnboardingPromptState() { + const pr = getAgentsConfigSection("packageResolution") ?? {}; + if (pr.onboardingPrompt === "off") return "off"; + if (pr.onboardingPrompt === "auto") return "auto"; + return "absent"; +} + +function normalizeOnboardingPrompt(raw) { + if (raw === "off") return "off"; + if (raw === "auto") return "auto"; + return "absent"; +} + +/** + * Deep-merge a patch into agents-conf.json (preserves unknown fields). + * `packageResolution.defaultGlobalRepos` and `autoSetup` are replaced when + * present in the patch (Consent Enable replaces the map with verified keys only). + * @param {object} patch + */ +export function mergeAgentsConfigPatch(patch) { + return withAgentsConfigLock(() => { + ensureAgentsConfigScaffoldUnlocked(); + const configPath = agentsConfigPath(); + let current = {}; + let existed = false; + try { + if (existsSync(configPath)) { + existed = true; + const parsed = JSON.parse(readFileSync(configPath, "utf8")); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error( + "agents-conf.json root must be a JSON object and was not overwritten", + ); + } + current = parsed; + } + } catch (err) { + // Never replace a malformed user config with a patch-only file. + if (existed) { + throw new Error( + `agents-conf.json is malformed and was not overwritten: ${err?.message ?? err}`, + ); + } + current = {}; + } + const next = deepMerge(current, patch); + if ( + patch?.packageResolution && + Object.prototype.hasOwnProperty.call( + patch.packageResolution, + "defaultGlobalRepos", + ) + ) { + next.packageResolution = next.packageResolution ?? {}; + next.packageResolution.defaultGlobalRepos = + patch.packageResolution.defaultGlobalRepos; + } + if ( + patch?.packageResolution && + Object.prototype.hasOwnProperty.call(patch.packageResolution, "autoSetup") + ) { + next.packageResolution = next.packageResolution ?? {}; + next.packageResolution.autoSetup = patch.packageResolution.autoSetup; + } + mkdirSync(path.dirname(configPath), { recursive: true }); + const tmp = `${configPath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`); + renameSync(tmp, configPath); + memoizedRaw = undefined; + memoizedMtimeMs = undefined; + return next; + }); +} + +function deepMerge(base, patch) { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) return patch; + const out = + base && typeof base === "object" && !Array.isArray(base) ? { ...base } : {}; + for (const [k, v] of Object.entries(patch)) { + if (v && typeof v === "object" && !Array.isArray(v)) { + out[k] = deepMerge(out[k], v); + } else { + out[k] = v; + } + } + return out; +} + export function getGlobalLogLevel() { return loadAgentsConfig().logLevel; } /** - * Package types the admin declares globally (governance source). Governance is - * the UNION of these and any workspace `.jfrog/local` repositories; the workspace - * side is added by the resolver (workspace-dependent, per-session). + * Package types the admin declares globally (the governance boundary). + * Workspace files may override repository keys for these types but cannot add + * new governed types. * @returns {string[]} defaultGlobalRepos keys (unordered) */ export function globalDeclaredTypes() { @@ -233,7 +451,7 @@ export function normalizeRepoMap(raw) { if (!raw || typeof raw !== "object") return {}; const out = {}; for (const [type, key] of Object.entries(raw)) { - if (typeof key === "string" && key.trim()) out[type] = key.trim(); + if (isSafeRepoKey(key?.trim())) out[type] = key.trim(); } return out; } diff --git a/plugins/jfrog/modules/core/entry.mjs b/plugins/jfrog/modules/core/entry.mjs new file mode 100644 index 0000000..476d681 --- /dev/null +++ b/plugins/jfrog/modules/core/entry.mjs @@ -0,0 +1,36 @@ +// Shared "was this module run as the CLI entrypoint?" check for the adapters. +// +// Claude invokes hooks as `${CLAUDE_PLUGIN_ROOT}/modules/.mjs`, and a +// plugin install directory is often a symlink. Node resolves the main entry to +// its real path before assigning import.meta.url, so comparing against a raw +// path.resolve(process.argv[1]) reports false under a symlinked layout and the +// hook silently becomes a no-op with exit code 0. Compare against both. + +import { realpathSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +/** + * @param {string} moduleUrl — the caller's import.meta.url + * @param {string} [entry] — defaults to process.argv[1] + */ +export function isMainEntry(moduleUrl, entry = process.argv[1]) { + if (!entry) return false; + + try { + const resolved = path.resolve(entry); + let real = resolved; + try { + real = realpathSync(resolved); + } catch { + // Entry may not exist on disk (e.g. a virtual entrypoint); use as-is. + } + return ( + moduleUrl === pathToFileURL(real).href || + moduleUrl === pathToFileURL(resolved).href + ); + } catch { + return false; + } +} diff --git a/plugins/jfrog/modules/core/io.mjs b/plugins/jfrog/modules/core/io.mjs index 13db450..230acaf 100644 --- a/plugins/jfrog/modules/core/io.mjs +++ b/plugins/jfrog/modules/core/io.mjs @@ -1,4 +1,4 @@ -// Shared stdin helpers for the subprocess-style adapters (Claude, Cursor). +// Shared stdin helpers for subprocess-style adapters (Claude, Cursor, VS Code). // // Hooks deliver their JSON payload on stdin immediately; in non-hook contexts // (CI, npm scripts, terminal smoke tests) nothing arrives, so we bail out after @@ -71,29 +71,47 @@ export function parseSessionId(stdinRaw) { } } +// Claude's documented SessionStart sources. VS Code Copilot documents only +// "new", so the two sets stay disjoint and neither can claim the other's +// sessions. +const CLAUDE_SESSION_SOURCES = new Set([ + "startup", + "resume", + "clear", + "compact", +]); + // Positively identify the harness that invoked this hook from its stdin -// payload. Returns "cursor", "claude_code", or null when it can't tell -// (no stdin — e.g. terminal smoke tests — or an unrecognized shape). +// payload. Returns "cursor", "copilot", "claude_code", or null when no harness +// left a fingerprint (no stdin — e.g. terminal smoke tests — or a shape none of +// them own). // // Why this matters: Cursor reads sessionStart hooks from BOTH // ~/.cursor/hooks.json AND ~/.claude/settings.json. Without this, a Cursor // session fires the Claude adapter too, double-injecting the policy. Each // adapter uses this to no-op when a different harness invoked it. // -// Cursor: cursor_version / agent_type. Claude: transcript_path / hook_event_name / -// session_id. Cursor also reads ~/.claude/settings.json, so each adapter no-ops -// when a different harness invoked it. +// Every branch below is a signal exactly one harness documents, and null means +// "can't tell". An adapter is only ever registered by the harness it serves, so +// a payload no harness claims is left to whichever adapter was invoked. export function detectHarness(stdinRaw) { if (!stdinRaw) return null; try { const p = JSON.parse(stdinRaw); if (!p) return null; + // Cursor stamps its own version/agent on every hook payload. if (p.cursor_version || p.agent_type === "cursor") { return "cursor"; } - if (p.transcript_path || p.hook_event_name || p.session_id) { - return "claude_code"; + if (p.hook_event_name === "SessionStart") { + // Copilot's documented `new` source is decisive. Current VS Code payloads + // also include a transcript_path, so path presence cannot classify Claude + // before the source is checked. + if (p.source === "new") return "copilot"; + if (CLAUDE_SESSION_SOURCES.has(p.source)) return "claude_code"; } + // Claude writes a transcript for non-SessionStart hooks too. + if (p.transcript_path) return "claude_code"; } catch { // stdin wasn't JSON — can't tell. } @@ -102,7 +120,8 @@ export function detectHarness(stdinRaw) { /** * Workspace roots for this hook invocation. - * Cursor: workspace_roots[]. Claude: payload cwd. Fallback: process.cwd(). + * Cursor: workspace_roots[]. Claude and VS Code Copilot: payload cwd. + * Fallback: process.cwd(). * * @param {string} [stdinRaw] * @returns {string[]} diff --git a/plugins/jfrog/modules/core/jf-identity.mjs b/plugins/jfrog/modules/core/jf-identity.mjs index b910d39..56a33e6 100644 --- a/plugins/jfrog/modules/core/jf-identity.mjs +++ b/plugins/jfrog/modules/core/jf-identity.mjs @@ -2,18 +2,24 @@ // we auth to it?". Used by feature-flag.mjs and resolver.mjs. // // Identity ALWAYS comes from `jf config`. `jf config export [serverId]` returns -// base64(JSON({ url, accessToken, serverId, ... })) for the chosen (or default) -// server. We require both `url` AND `accessToken` (Bearer-only path). +// base64(JSON({ url, accessToken, user, password, serverId, ... })) for the +// chosen (or default) server. A usable identity needs a platform `url` plus a +// credential: an access token (Bearer) OR username + password / API key +// (Basic). Access token wins when both are present (mirrors `jf setup`). +// +// After credentials parse, an optional readiness probe (Artifactory ping) +// rejects expired/revoked/unreachable credentials so the feature flag can +// fall into pending instead of "routing with empty repos". // // If `jf` is not on PATH, has no configured servers, or the chosen server has -// no access token, identity is null and the feature flag falls into the -// `missing-identity` path (hook goes no-op, fail closed). Same behaviour as -// before — only the configuration mechanism is simpler. +// no usable credential (e.g. SSH-key-only), identity is null and the feature +// flag falls into the `missing-identity` path (hook goes no-op, fail closed). // -// One subprocess per hook process. Cached after first call within the same -// process (feature-flag + resolver share one export). Not persisted across -// sessions — `jf config export` is local and fast enough to run every time. +// Config export is cached per process. Probe results are cached separately +// (async) so feature-flag can await readiness without making getPlatformIdentity +// async. +import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import process from "node:process"; @@ -21,35 +27,55 @@ import { createLogger } from "./logger.mjs"; const log = createLogger("jf-identity"); -// Wire-format cause codes for getPlatformIdentity() / pending remediation. -// Single source of truth — import this instead of repeating string literals. +/** Wire-format cause codes for getPlatformIdentity() / pending remediation. */ export const IdentityCause = Object.freeze({ OK: "ok", JF_NOT_INSTALLED: "jf-not-installed", JF_NOT_CONFIGURED: "jf-not-configured", + /** Server present but credential shape unusable (e.g. SSH-key-only). */ + JF_UNSUPPORTED_AUTH: "jf-unsupported-auth", + /** Credential present but Artifactory rejected it (401/403). */ + JF_AUTH_FAILED: "jf-auth-failed", + /** Probe timed out / network / non-auth HTTP failure. */ + JF_UNREACHABLE: "jf-unreachable", + /** Platform URL is not https — refuse to send credentials in cleartext. */ + INSECURE_URL: "insecure-url", }); +/** + * Credentials must never travel in cleartext. `jf` accepts http:// servers; + * callers that send Authorization headers must gate on https first. + * @param {{ url?: string } | string | null | undefined} identityOrUrl + */ +export function isHttpsIdentityUrl(identityOrUrl) { + try { + const raw = + typeof identityOrUrl === "string" + ? identityOrUrl + : (identityOrUrl?.url ?? ""); + return new URL(String(raw)).protocol === "https:"; + } catch { + return false; + } +} +const PROBE_TIMEOUT_MS = 3_000; + // Module-scope cache. Keyed by the requested serverId hint (`undefined` // means "whatever jf considers default"). Stores the full resolved object, // including null when jf config produced nothing usable. const CACHE = new Map(); +// Probe results are cached for the process lifetime (each hook is a fresh +// process, so there's nothing to expire within one). Both ok and non-ok +// results are memoized so feature-flag + resolver share one round-trip. +/** @type {Map} */ +const PROBE_CACHE = new Map(); function normalizeUrl(u) { if (!u) return ""; return String(u).replace(/\/+$/, ""); } -// Resolution cause. OK means identity is present; the two failure causes -// drive cause-aware remediation in the pending path: -// JF_NOT_INSTALLED — `jf` is not on PATH / could not be executed. -// JF_NOT_CONFIGURED — `jf` ran but produced no usable server identity -// (non-zero exit, empty/undecodable export, or a -// server entry missing url/accessToken). function jfConfigIdentity(serverId) { - // `jf config export` writes base64(JSON) to stdout for the requested - // server (or the default when no arg). We split the failure space into - // "jf could not be run" (not-installed) vs "jf ran but has no usable - // server" (not-configured) so the caller can give targeted remediation. const args = ["config", "export"]; if (serverId) args.push(serverId); @@ -57,8 +83,6 @@ function jfConfigIdentity(serverId) { try { result = spawnSync("jf", args, { encoding: "utf8", - // jf config export reads no stdin and writes a single base64 line - // (no terminal interaction). 2s is plenty even for cold spawns. timeout: 2000, stdio: ["ignore", "pipe", "pipe"], }); @@ -68,9 +92,10 @@ function jfConfigIdentity(serverId) { } if (result.error) { - // ENOENT (and any other spawn error) means the binary could not be - // executed — treat as not installed. - log.debug("jf spawn error", { code: result.error.code, message: result.error.message }); + log.debug("jf spawn error", { + code: result.error.code, + message: result.error.message, + }); return { identity: null, cause: IdentityCause.JF_NOT_INSTALLED }; } if (result.status !== 0) { @@ -92,32 +117,228 @@ function jfConfigIdentity(serverId) { const json = Buffer.from(blob, "base64").toString("utf8"); parsed = JSON.parse(json); } catch (err) { - log.warn("jf config export blob not decodable", { error: err?.message ?? String(err) }); + log.warn("jf config export blob not decodable", { + error: err?.message ?? String(err), + }); return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; } const url = normalizeUrl(parsed?.url); const token = parsed?.accessToken ?? ""; + const user = parsed?.user ?? ""; + const password = parsed?.password ?? ""; const resolvedServerId = parsed?.serverId ?? serverId ?? null; - if (!url || !token) { - log.debug("jf config export missing url or accessToken", { + if (!url) { + log.debug("jf config export missing url", { serverId: resolvedServerId, - hasUrl: Boolean(url), + hasUrl: false, hasToken: Boolean(token), + hasUser: Boolean(user), + hasPassword: Boolean(password), }); return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED }; } + // Access token wins when both are present (mirrors jf setup precedence). + let auth = null; + if (token) { + auth = { kind: "bearer", token }; + } else if (user && password) { + auth = { kind: "basic", user, password }; + } + + if (!auth) { + log.debug("jf config export has url but no usable credential", { + serverId: resolvedServerId, + hasUrl: true, + hasToken: Boolean(token), + hasUser: Boolean(user), + hasPassword: Boolean(password), + }); + return { identity: null, cause: IdentityCause.JF_UNSUPPORTED_AUTH }; + } + + log.debug("jf config export identity accepted", { + serverId: resolvedServerId, + hasUrl: true, + authKind: auth.kind, + }); + return { - identity: { url, token, serverId: resolvedServerId, source: "jf-config" }, + identity: { + url, + serverId: resolvedServerId, + source: "jf-config", + auth, + }, cause: IdentityCause.OK, }; } -// Public — returns { identity, cause }: -// identity: { url, token, serverId, source } | null -// cause: IdentityCause.OK | JF_NOT_INSTALLED | JF_NOT_CONFIGURED +/** + * HTTP Authorization header value for Artifactory API calls, or null. + * Rejects credentials with CR/LF so Node never throws a header error that + * echoes the secret in `err.message`. + */ +export function authHeader(identity) { + const auth = identity?.auth; + if (!auth) return null; + if (auth.kind === "bearer") { + const token = String(auth.token ?? ""); + if (!token || /[\r\n]/.test(token)) return null; + return `Bearer ${token}`; + } + if (auth.kind === "basic") { + const user = String(auth.user ?? ""); + const password = String(auth.password ?? ""); + if (!user || !password || /[\r\n]/.test(user) || /[\r\n]/.test(password)) { + return null; + } + return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; + } + return null; +} + +/** Strip credential material from error strings before logging. */ +export function safeErrorMessage(err) { + const raw = err?.message ?? String(err ?? ""); + return raw + .replace(/Bearer\s+\S+/gi, "Bearer ") + .replace(/Basic\s+\S+/gi, "Basic "); +} + +function probeCacheKey(identity) { + const auth = identity?.auth; + if (!auth) return "none"; + const url = identity.url ?? ""; + if (auth.kind === "bearer") { + const digest = createHash("sha256") + .update(`bearer\0${auth.token ?? ""}`) + .digest("hex") + .slice(0, 16); + return `${url}|bearer|${digest}`; + } + const digest = createHash("sha256") + .update(`basic\0${auth.user ?? ""}\0${auth.password ?? ""}`) + .digest("hex") + .slice(0, 16); + return `${url}|basic|${digest}`; +} + +/** Test hooks only apply when the unit/integration harness sets this. */ +function testHarnessActive() { + return process.env.JFROG_TEST_HARNESS === "1"; +} + +function syntheticProbeResult() { + if (!testHarnessActive()) return null; + const mode = process.env.JFROG_TEST_IDENTITY_PROBE; + if (!mode || mode === "skip") return null; + if (mode === "ok") return { ok: true, cause: IdentityCause.OK }; + if (mode === "401" || mode === "403" || mode === "auth-failed") { + return { ok: false, cause: IdentityCause.JF_AUTH_FAILED }; + } + if (mode === "error" || mode === "unreachable") { + return { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } + return null; +} + +/** + * Probe Artifactory with the resolved credentials. Fail-closed: any non-OK + * response or network error means the identity is not ready for routing. + * + * Test hooks (require `JFROG_TEST_HARNESS=1` — never honored in production): + * JFROG_TEST_IDENTITY_PROBE=skip — do not probe; treat as ok + * ok / 401 / error — synthetic results + * + * Production kill switch: `JF_AGENT_IDENTITY_PROBE=0` skips the probe. + * + * @param {object | null} identity + * @returns {Promise<{ ok: boolean, cause: string }>} + */ +export async function probePlatformIdentity(identity) { + if (!identity) { + return { ok: false, cause: IdentityCause.JF_NOT_CONFIGURED }; + } + + const synthetic = syntheticProbeResult(); + if (synthetic) return synthetic; + + if (testHarnessActive() && process.env.JFROG_TEST_IDENTITY_PROBE === "skip") { + return { ok: true, cause: IdentityCause.OK }; + } + + if (!isHttpsIdentityUrl(identity)) { + log.warn("refusing identity probe over a non-HTTPS platform URL"); + const result = { ok: false, cause: IdentityCause.INSECURE_URL }; + const keyEarly = probeCacheKey(identity); + PROBE_CACHE.set(keyEarly, result); + return result; + } + + if (process.env.JF_AGENT_IDENTITY_PROBE === "0") { + return { ok: true, cause: IdentityCause.OK }; + } + + const key = probeCacheKey(identity); + const cached = PROBE_CACHE.get(key); + if (cached) { + return { ok: cached.ok, cause: cached.cause }; + } + + const authorization = authHeader(identity); + if (!authorization) { + const result = { ok: false, cause: IdentityCause.JF_UNSUPPORTED_AUTH }; + PROBE_CACHE.set(key, result); + return result; + } + + // Auth-required endpoint: `system/ping` is anonymous-capable, so a + // revoked/expired token would still return 200 and wrongly pass readiness. + // `system/version` requires an authenticated (non-anonymous) caller. + const pingUrl = `${identity.url}/artifactory/api/system/version`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + /** @type {{ ok: boolean, cause: string }} */ + let result; + try { + const res = await fetch(pingUrl, { + method: "GET", + headers: { Authorization: authorization }, + signal: controller.signal, + }); + if (res.status === 401 || res.status === 403) { + result = { ok: false, cause: IdentityCause.JF_AUTH_FAILED }; + } else if (!res.ok) { + result = { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } else { + result = { ok: true, cause: IdentityCause.OK }; + } + } catch (err) { + log.debug("identity probe failed", { + url: pingUrl, + error: safeErrorMessage(err), + }); + result = { ok: false, cause: IdentityCause.JF_UNREACHABLE }; + } finally { + clearTimeout(timer); + } + + log.debug("identity probe result", { + url: identity.url, + ok: result.ok, + cause: result.cause, + }); + PROBE_CACHE.set(key, result); + return result; +} + +/** + * Config-only identity (sync). Does not probe reachability. + * @returns {{ identity: object | null, cause: string }} + */ export function getPlatformIdentity() { const hint = undefined; if (CACHE.has(hint)) return CACHE.get(hint); @@ -127,6 +348,7 @@ export function getPlatformIdentity() { log.debug("identity from jf-config", { serverId: status.identity.serverId, url: status.identity.url, + authKind: status.identity.auth?.kind, }); } else { log.debug("no platform identity", { cause: status.cause }); @@ -135,20 +357,109 @@ export function getPlatformIdentity() { return status; } -/** Test-only — reset module cache between in-process scenarios. */ +/** + * Config identity + readiness probe. Prefer this from async session paths + * (feature-flag) so dead tokens fail closed to pending. + * @returns {Promise<{ identity: object | null, cause: string }>} + */ +export async function getReadyPlatformIdentity() { + const status = getPlatformIdentity(); + if (!status.identity) return status; + + const probe = await probePlatformIdentity(status.identity); + if (probe.ok) return status; + + // Rejected / structurally-unusable credentials are a stable fact → fail + // closed to pending so we don't inject "routing" with an unusable identity. + if ( + probe.cause === IdentityCause.JF_AUTH_FAILED || + probe.cause === IdentityCause.JF_UNSUPPORTED_AUTH || + probe.cause === IdentityCause.INSECURE_URL + ) { + log.debug("identity not ready after probe", { cause: probe.cause }); + return { identity: null, cause: probe.cause }; + } + + // Transient failure (timeout / network / 5xx): keep routing best-effort + // rather than downgrading a healthy setup to pending on a blip. The resolver + // already fails safe per-repo (keeps prior cache, skips empty writes). + log.warn("identity probe unreachable — routing best-effort", { + cause: probe.cause, + }); + return status; +} + +/** Test-only — reset module caches between in-process scenarios. */ export function clearPlatformIdentityCache() { CACHE.clear(); + PROBE_CACHE.clear(); } -// Short label for log lines / status output, e.g. "jf-config:". export function identityLabel(identity) { if (!identity) return "none"; return identity.serverId ? `jf-config:${identity.serverId}` : "jf-config"; } -// CLI: -// node lib/jf-identity.mjs — JSON with token redacted -// node lib/jf-identity.mjs --label — single line: "