Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "jfrog",
"displayName": "JFrog",
"description": "Official JFrog plugin. Connect Claude Code to JFrog to manage, secure, and govern your software supply chain. Give agents the context to build secure, compliant software.",
"version": "0.2.16",
"version": "0.2.17",
"author": {
"name": "JFrog Ltd.",
"email": "devrel@jfrog.com",
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/sync-modules-vendor.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"repo": "JFROG/jfrog-agent-hooks",
"pin": "jfrog-agent-hooks/v0.7.0",
"pin": "jfrog-agent-hooks/v0.8.1",
"paths": [
"modules"
]
Expand Down
1 change: 1 addition & 0 deletions modules/assets/agents-default-conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"npm": "npm-virtual",
"pypi": "pypi-virtual",
"maven": "maven-virtual",
"gradle": "gradle-virtual",
"go": "go-virtual",
"docker": "docker-virtual",
"helm": "helm-virtual",
Expand Down
55 changes: 42 additions & 13 deletions modules/core/io.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,59 @@

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);
};
const idleTimer = setTimeout(settle, idleMs);

idleTimer = setTimeout(settle, idleMs);
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
idleTimer.refresh();
});
process.stdin.on("end", () => {
clearTimeout(idleTimer);
settle();
});
process.stdin.on("error", () => {
clearTimeout(idleTimer);
settle();
});
process.stdin.on("data", onData);
process.stdin.on("end", settle);
process.stdin.on("error", settle);
});
}

Expand Down
36 changes: 22 additions & 14 deletions modules/core/jf-identity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ 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.
export const IdentityCause = Object.freeze({
OK: "ok",
JF_NOT_INSTALLED: "jf-not-installed",
JF_NOT_CONFIGURED: "jf-not-configured",
});

// 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.
Expand All @@ -31,12 +39,12 @@ function normalizeUrl(u) {
return String(u).replace(/\/+$/, "");
}

// Resolution cause. `ok` means identity is present; the two failure causes
// 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).
// 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
Expand All @@ -56,27 +64,27 @@ function jfConfigIdentity(serverId) {
});
} catch (err) {
log.debug("jf spawn threw", { error: err?.message ?? String(err) });
return { identity: null, cause: "jf-not-installed" };
return { identity: null, cause: IdentityCause.JF_NOT_INSTALLED };
}

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 });
return { identity: null, cause: "jf-not-installed" };
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: "jf-not-configured" };
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: "jf-not-configured" };
return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED };
}

let parsed;
Expand All @@ -85,7 +93,7 @@ function jfConfigIdentity(serverId) {
parsed = JSON.parse(json);
} catch (err) {
log.warn("jf config export blob not decodable", { error: err?.message ?? String(err) });
return { identity: null, cause: "jf-not-configured" };
return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED };
}

const url = normalizeUrl(parsed?.url);
Expand All @@ -98,18 +106,18 @@ function jfConfigIdentity(serverId) {
hasUrl: Boolean(url),
hasToken: Boolean(token),
});
return { identity: null, cause: "jf-not-configured" };
return { identity: null, cause: IdentityCause.JF_NOT_CONFIGURED };
}

return {
identity: { url, token, serverId: resolvedServerId, source: "jf-config" },
cause: "ok",
cause: IdentityCause.OK,
};
}

// Public — returns { identity, cause }:
// identity: { url, token, serverId, source } | null
// cause: "ok" | "jf-not-installed" | "jf-not-configured"
// cause: IdentityCause.OK | JF_NOT_INSTALLED | JF_NOT_CONFIGURED
export function getPlatformIdentity() {
const hint = undefined;
if (CACHE.has(hint)) return CACHE.get(hint);
Expand Down Expand Up @@ -155,7 +163,7 @@ if (isMain) {
}
if (!identity) {
const hint =
cause === "jf-not-installed"
cause === IdentityCause.JF_NOT_INSTALLED
? "`jf` is not installed. Install the JFrog CLI, then run `jf config add`."
: "No configured JFrog server. Run `jf config add` (must use access-token auth).";
console.error(`No platform identity (${cause}). ${hint}`);
Expand Down
Loading
Loading