From 580a544ac39b36ae55815616fa44de2e0909c45d Mon Sep 17 00:00:00 2001 From: "jfrog-skills-release-bot[bot]" <276080306+jfrog-skills-release-bot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:51:06 +0000 Subject: [PATCH] chore: sync modules to v0.9.0 --- .cursor-plugin/marketplace.json | 2 +- .github/scripts/sync-modules-vendor.json | 2 +- plugins/jfrog/.cursor-plugin/plugin.json | 2 +- .../modules/assets/agents-default-conf.json | 11 +- .../jfrog/modules/claude-session-start.mjs | 12 +- .../jfrog/modules/copilot-session-start.mjs | 90 ++ plugins/jfrog/modules/core/agents-config.mjs | 9 +- plugins/jfrog/modules/core/io.mjs | 37 +- plugins/jfrog/modules/core/jf-identity.mjs | 362 +++++++- plugins/jfrog/modules/core/logger.mjs | 18 +- plugins/jfrog/modules/core/run-capability.mjs | 20 +- .../jfrog/modules/cursor-session-start.mjs | 12 +- .../scripts/eager-setup-receipt.mjs | 7 +- .../scripts/eager-setup.mjs | 148 +++- .../scripts/feature-flag.mjs | 33 +- .../scripts/render-instruction.mjs | 64 +- .../package-resolution/scripts/repo-types.mjs | 6 + .../package-resolution/scripts/resolver.mjs | 251 ++++-- .../scripts/setup-conflict.mjs | 807 ++++++++++++++++++ .../scripts/workspace-config.mjs | 3 +- .../templates/package-resolution.md | 10 + 21 files changed, 1730 insertions(+), 176 deletions(-) create mode 100644 plugins/jfrog/modules/copilot-session-start.mjs create mode 100644 plugins/jfrog/modules/package-resolution/scripts/setup-conflict.mjs diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 52db49a..8e1d4f1 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.12", + "version": "0.5.13", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 4ca704b..639c8c9 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.9.0", "paths": [ "modules" ] diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index 45628dc..693fbe1 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.12", + "version": "0.5.13", "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-default-conf.json b/plugins/jfrog/modules/assets/agents-default-conf.json index 3d13fce..e2b035c 100644 --- a/plugins/jfrog/modules/assets/agents-default-conf.json +++ b/plugins/jfrog/modules/assets/agents-default-conf.json @@ -4,16 +4,7 @@ "enabled": false, "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" - }, + "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/agents-config.mjs b/plugins/jfrog/modules/core/agents-config.mjs index 64a43df..bf10d77 100644 --- a/plugins/jfrog/modules/core/agents-config.mjs +++ b/plugins/jfrog/modules/core/agents-config.mjs @@ -13,6 +13,7 @@ import { 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( @@ -174,9 +175,9 @@ export function getGlobalLogLevel() { } /** - * 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 +234,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/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..33367d1 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,37 @@ 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", }); +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 +65,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 +74,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 +99,219 @@ 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 (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 +321,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 +330,102 @@ 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 + ) { + 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: "