From b71208b762dea98656173cd867237d010c95fefc Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 6 May 2026 16:42:49 +0200 Subject: [PATCH 01/55] chore: Refactor AsyncJobManager --- .../util/_common/helper/AsyncJobManager.java | 50 ++++++++++++------- .../mcp/runner/MCPToolAsyncJobManager.java | 7 ++- .../helper/RPCMethodHandlerFcliExecute.java | 6 ++- .../helper/RPCMethodHandlerFnCall.java | 6 ++- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncJobManager.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncJobManager.java index 8919f9fee87..b78ff6e502e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncJobManager.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncJobManager.java @@ -20,7 +20,9 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.cli.util.StdioHelper; @@ -49,6 +51,18 @@ public static class Config { @Builder.Default int bgThreads = DEFAULT_BG_THREADS; } + /** + * Descriptor for starting a background task. + */ + @Value @Builder + public static class TaskDescriptor { + String jobId; + IAsyncTask task; + @Builder.Default IJobEventListener listener = IJobEventListener.NOOP; + @Builder.Default String description = ""; + Consumer executionContextConfigurer; + } + private final Map jobs = new ConcurrentHashMap<>(); private final ExecutorService backgroundExecutor; @@ -66,10 +80,20 @@ public AsyncJobManager(Config config) { } /** - * Start a background job with the given {@code jobId}, dispatching events to the listener. - * Use this when the caller needs a deterministic/semantic job identifier. + * Start a background job described by the given {@link TaskDescriptor}. */ - public String startBackground(String jobId, IAsyncTask task, IJobEventListener listener, String description) { + public String startBackground(TaskDescriptor descriptor) { + if ( descriptor == null ) { + throw new IllegalArgumentException("TaskDescriptor must be specified"); + } + var task = descriptor.getTask(); + if ( task == null ) { + throw new IllegalArgumentException("TaskDescriptor.task must be specified"); + } + var jobId = descriptor.getJobId() == null ? UUID.randomUUID().toString() : descriptor.getJobId(); + var listener = descriptor.getListener(); + var description = descriptor.getDescription() == null ? "" : descriptor.getDescription(); + var executionContextConfigurer = descriptor.getExecutionContextConfigurer(); var entry = new JobEntry(jobId, description); jobs.put(jobId, entry); @@ -77,7 +101,10 @@ public String startBackground(String jobId, IAsyncTask task, IJobEventListener l var future = CompletableFuture.runAsync(() -> { entry.thread = Thread.currentThread(); - FcliExecutionContextHolder.pushNew(); + var context = FcliExecutionContextHolder.pushNew(); + if ( executionContextConfigurer != null ) { + executionContextConfigurer.accept(context); + } // Register per-thread progress callback so that progress writer // messages are forwarded to the job event listener as notifications. // Masking is applied by StdioHelper before invoking the callback. @@ -117,21 +144,6 @@ public String startBackground(String jobId, IAsyncTask task, IJobEventListener l return jobId; } - /** - * Start a background job, dispatching events to the given listener. - * Returns a fresh {@code jobId}. - */ - public String startBackground(IAsyncTask task, IJobEventListener listener, String description) { - return startBackground(UUID.randomUUID().toString(), task, listener, description); - } - - /** - * Start a background job with no-op listener and no description. - */ - public String startBackground(IAsyncTask task) { - return startBackground(task, IJobEventListener.NOOP, ""); - } - /** * Cancel a running job. Returns {@code true} if the job was found and cancelled. */ diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java index c7f980f7c14..d9c280ad0de 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java @@ -73,7 +73,12 @@ public InProgressEntry getOrStartBackground(String jobId, boolean refresh, IAsyn return new InProgressEntry(jobId, cachingListener, jobTokens.get(jobId)); } // Start new background job with the semantic jobId - delegate.startBackground(jobId, task, cachingListener, "mcp:" + jobId); + delegate.startBackground(AsyncJobManager.TaskDescriptor.builder() + .jobId(jobId) + .task(task) + .listener(cachingListener) + .description("mcp:" + jobId) + .build()); var future = delegate.getFuture(jobId); if (future != null) { var jobToken = jobManager.trackFuture("async_job", future, diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java index 8666effca27..97c19d56218 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java @@ -82,7 +82,11 @@ public JsonNode execute(JsonNode params) throws RPCMethodException { var task = new AsyncTaskFcliCommand(command, collectRecords); var description = "fcli " + command; - var jobId = asyncJobManager.startBackground(task, effectiveListener, description); + var jobId = asyncJobManager.startBackground(AsyncJobManager.TaskDescriptor.builder() + .task(task) + .listener(effectiveListener) + .description(description) + .build()); if (collector != null) { return RPCWaitHelper.awaitOrFallback(collector, waitConfig, jobId, jobType, cacheConfig, collectRecords); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFnCall.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFnCall.java index 49a3d04f41b..4621e207817 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFnCall.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFnCall.java @@ -84,7 +84,11 @@ public JsonNode execute(JsonNode params) throws RPCMethodException { var argsNode = buildArgsNode(params); var task = new AsyncTaskActionFunction(executor, argsNode); var description = "fn:" + name; - var jobId = asyncJobManager.startBackground(task, effectiveListener, description); + var jobId = asyncJobManager.startBackground(AsyncJobManager.TaskDescriptor.builder() + .task(task) + .listener(effectiveListener) + .description(description) + .build()); if (collector != null) { return RPCWaitHelper.awaitOrFallback(collector, waitConfig, jobId, "records", cacheConfig, true); From 0fc9066a10c66740366ccfe215772ecb17a91036 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 6 May 2026 16:56:02 +0200 Subject: [PATCH 02/55] chore: Refactor FcliExecutionContext, add transient session support --- .../action/runner/ActionFunctionExecutor.java | 2 +- .../action/runner/ActionRunnerVars.java | 14 +-- .../common/cli/util/FcliExecutionContext.java | 42 +++++++-- .../cli/util/FcliExecutionContextTest.java | 91 +++++++++++++++++++ .../concurrent/FcliConcurrencyTest.java | 4 +- 5 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java index e6e20f26c18..a7e9fb59f56 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java @@ -28,7 +28,7 @@ * and delegates to {@link ActionFunctionSpelFunctions#call(String, Object...)}. *

* All executors created for the same server share a single - * {@link FcliExecutionContext} so that {@code globalValues} persist across + * {@link FcliExecutionContext} so that {@code globalActionValues} persist across * invocations. The shared context is pushed onto the calling thread's stack * during execution and popped afterwards. *

diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java index b6d50cfb2b8..0c03a8f5c4f 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java @@ -53,7 +53,7 @@ public final class ActionRunnerVars { private static final String CLI_OPTIONS_VAR_NAME = "cli"; private static final String[] PROTECTED_VAR_NAMES = {GLOBAL_VAR_NAME, CLI_OPTIONS_VAR_NAME}; @Getter private final ObjectNode values; - private final ObjectNode globalValues; + private final ObjectNode globalActionValues; private IConfigurableSpelEvaluator spelEvaluator; private final ActionRunnerVars parent; private final boolean propagateToParent; @@ -67,8 +67,8 @@ public final class ActionRunnerVars { public ActionRunnerVars(IConfigurableSpelEvaluator spelEvaluator, ObjectNode cliOptions) { this.spelEvaluator = spelEvaluator; this.values = objectMapper.createObjectNode(); - this.globalValues = FcliExecutionContextHolder.current().getGlobalValues(); - this.values.set(GLOBAL_VAR_NAME, this.globalValues); + this.globalActionValues = FcliExecutionContextHolder.current().getGlobalActionValues(); + this.values.set(GLOBAL_VAR_NAME, this.globalActionValues); this.values.set(CLI_OPTIONS_VAR_NAME, cliOptions); this.parent = null; this.propagateToParent = true; @@ -80,7 +80,7 @@ public ActionRunnerVars(IConfigurableSpelEvaluator spelEvaluator, ObjectNode cli private ActionRunnerVars(ActionRunnerVars parent, boolean propagateToParent) { this.spelEvaluator = parent.spelEvaluator; this.values = JsonHelper.shallowCopy(parent.values); - this.globalValues = parent.globalValues; + this.globalActionValues = parent.globalActionValues; this.parent = parent; this.propagateToParent = propagateToParent; } @@ -157,8 +157,8 @@ public final void set(String name, JsonNode value) { Function getter = values::get; if ( name.startsWith("global.") ) { name = name.replaceAll("^global\\.", ""); - setter = globalValues::set; - getter = globalValues::get; + setter = globalActionValues::set; + getter = globalActionValues::get; } var finalName = name; // Needed for lambda below logDebug(()->String.format("Set %s: %s", finalName, toDebugString(value))); @@ -180,7 +180,7 @@ public final void rm(String name) { Consumer unsetter = this::_unset; if ( name.startsWith("global.") ) { name = name.replaceAll("^global\\.", ""); - unsetter = globalValues::remove; + unsetter = globalActionValues::remove; } rejectProtectedVarNames(name); var finalName = name; // Needed for lambda below diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java index 078d6f4aea4..8bdc1ca576a 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java @@ -18,6 +18,7 @@ import java.nio.file.Path; import java.security.SecureRandom; import java.util.Base64; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -25,32 +26,55 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.crypto.helper.EncryptionHelper; import com.fortify.cli.common.rest.unirest.UnirestContext; +import com.fortify.cli.common.session.helper.ISessionDescriptor; + +import lombok.Getter; /** * Per-top-level execution context holding mutable execution-scoped state. - * The {@code globalValues} ObjectNode is backed by a {@link ConcurrentHashMap} + * The {@code globalActionValues} ObjectNode is backed by a {@link ConcurrentHashMap} * to allow safe concurrent access from multiple threads (e.g. async jobs, * server request handlers). */ public final class FcliExecutionContext { - private final ObjectNode globalValues = new ObjectNode(JsonNodeFactory.instance, new ConcurrentHashMap<>()); - private final UnirestContext unirestContext = new UnirestContext(); + @Getter private final ObjectNode globalActionValues = new ObjectNode(JsonNodeFactory.instance, new ConcurrentHashMap<>()); + @Getter private final UnirestContext unirestContext = new UnirestContext(); // Encryption helper used for encrypt/decrypt in this execution. Default to global DEFAULT. private volatile EncryptionHelper encryptionHelper = EncryptionHelper.DEFAULT; // Set of absolute file paths that were saved using ephemeral encryption during this execution private final Set ephemeralEncryptedFiles = ConcurrentHashMap.newKeySet(); + @Getter private final Map transientSessionDescriptors = new ConcurrentHashMap<>(); + + public void clearTransientSessionDescriptors() { + transientSessionDescriptors.clear(); + } + + public ISessionDescriptor getTransientSessionDescriptor(String type) { + return type == null ? null : transientSessionDescriptors.get(type); + } - public ObjectNode getGlobalValues() { return globalValues; } - public UnirestContext getUnirestContext() { return unirestContext; } + public void setTransientSessionDescriptor(ISessionDescriptor descriptor) { + if ( descriptor == null ) { + return; + } + transientSessionDescriptors.put(descriptor.getType(), descriptor); + } + + public void clearTransientSessionDescriptor(String type) { + if ( type != null ) { + transientSessionDescriptors.remove(type); + } + } public String info() { - return String.format("FcliExecutionContext@%s(%d) actionGlobalValues@%s(%d) unirestContext@%s(%s)", + return String.format("FcliExecutionContext@%s(%d) actionGlobalValues@%s(%d) unirestContext@%s(%s) transientSessions=%d", Integer.toHexString(System.identityHashCode(this)), FcliExecutionContextHolder.stackDepth(), - Integer.toHexString(System.identityHashCode(globalValues)), - globalValues.size(), + Integer.toHexString(System.identityHashCode(globalActionValues)), + globalActionValues.size(), Integer.toHexString(System.identityHashCode(unirestContext)), - unirestContext.getCachedInstanceCount()); + unirestContext.getCachedInstanceCount(), + transientSessionDescriptors.size()); } /** diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java new file mode 100644 index 00000000000..299e50792d3 --- /dev/null +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Date; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.session.helper.ISessionDescriptor; + +class FcliExecutionContextTest { + @Test + void transientSessionDescriptorsCanBeStoredByTypeAndCleared() { + var context = new FcliExecutionContext(); + var sscDescriptor = new DummySessionDescriptor("SSC"); + var fodDescriptor = new DummySessionDescriptor("FoD"); + + assertTrue(context.getTransientSessionDescriptors().isEmpty()); + assertNull(context.getTransientSessionDescriptor("SSC")); + assertFalse(context.info().contains("transientSessions=1")); + + context.setTransientSessionDescriptor(sscDescriptor); + context.setTransientSessionDescriptor(fodDescriptor); + + assertSame(sscDescriptor, context.getTransientSessionDescriptor("SSC")); + assertSame(fodDescriptor, context.getTransientSessionDescriptor("FoD")); + assertTrue(context.info().contains("transientSessions=2")); + + context.clearTransientSessionDescriptor("SSC"); + + assertNull(context.getTransientSessionDescriptor("SSC")); + assertSame(fodDescriptor, context.getTransientSessionDescriptor("FoD")); + + context.clearTransientSessionDescriptors(); + + assertTrue(context.getTransientSessionDescriptors().isEmpty()); + } + + @Test + void transientSessionDescriptorConvenienceSetterIndexesByType() { + var context = new FcliExecutionContext(); + var descriptor = new DummySessionDescriptor("dummy"); + + context.setTransientSessionDescriptor(descriptor); + + assertSame(descriptor, context.getTransientSessionDescriptor("dummy")); + } + + private static final class DummySessionDescriptor implements ISessionDescriptor { + private final String type; + + private DummySessionDescriptor(String type) { + this.type = type; + } + + @Override + public String getUrlDescriptor() { + return "dummy"; + } + + @Override + public Date getCreatedDate() { + return new Date(); + } + + @Override + public Date getExpiryDate() { + return null; + } + + @Override + public String getType() { + return type; + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/FcliConcurrencyTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/FcliConcurrencyTest.java index 5ed2d1f67d0..e93e839fc1c 100644 --- a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/FcliConcurrencyTest.java +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/FcliConcurrencyTest.java @@ -42,7 +42,7 @@ public void actionVarsAreIsolatedPerInvocation() throws InterruptedException, Ex var cli = JsonHelper.getObjectMapper().createObjectNode(); var vars = new ActionRunnerVars(null, cli); vars.set("global.foo", new TextNode("v1")); - return FcliExecutionContextHolder.current().getGlobalValues().get("foo").asText(); + return FcliExecutionContextHolder.current().getGlobalActionValues().get("foo").asText(); } finally { FcliExecutionContextHolder.pop(); } @@ -53,7 +53,7 @@ public void actionVarsAreIsolatedPerInvocation() throws InterruptedException, Ex var cli = JsonHelper.getObjectMapper().createObjectNode(); var vars = new ActionRunnerVars(null, cli); vars.set("global.foo", new TextNode("v2")); - return FcliExecutionContextHolder.current().getGlobalValues().get("foo").asText(); + return FcliExecutionContextHolder.current().getGlobalActionValues().get("foo").asText(); } finally { FcliExecutionContextHolder.pop(); } From a9199a0ef0f4ca1a3382a87f81868bdd247ac9f2 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 6 May 2026 17:01:41 +0200 Subject: [PATCH 03/55] chore: Add support for transient session descriptor --- .../AviatorAdminConfigDescriptorSupplier.java | 5 + .../AviatorUserSessionDescriptorSupplier.java | 5 + ...bstractSessionDescriptorSupplierMixin.java | 10 +- ...actSessionDescriptorSupplierMixinTest.java | 128 ++++++++++++++++++ .../FoDUnirestInstanceSupplierMixin.java | 5 + ...anCentralUnirestInstanceSupplierMixin.java | 5 + 6 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/mixin/AviatorAdminConfigDescriptorSupplier.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/mixin/AviatorAdminConfigDescriptorSupplier.java index 159ee7ef882..013017a5ce6 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/mixin/AviatorAdminConfigDescriptorSupplier.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/mixin/AviatorAdminConfigDescriptorSupplier.java @@ -29,6 +29,11 @@ protected final AviatorAdminConfigDescriptor getSessionDescriptor(String configN return AviatorAdminConfigHelper.instance().get(configName, true); } + @Override + protected String getSessionDescriptorType() { + return AviatorAdminConfigHelper.instance().getType(); + } + @Override public ISessionNameSupplier getSessionNameSupplier() { return configNameSupplier; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/mixin/AviatorUserSessionDescriptorSupplier.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/mixin/AviatorUserSessionDescriptorSupplier.java index 9b5cf248154..bd246df3763 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/mixin/AviatorUserSessionDescriptorSupplier.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/mixin/AviatorUserSessionDescriptorSupplier.java @@ -27,4 +27,9 @@ public class AviatorUserSessionDescriptorSupplier extends AbstractSessionDescrip public final AviatorUserSessionDescriptor getSessionDescriptor(String sessionName) { return AviatorUserSessionHelper.instance().get(sessionName, true); } + + @Override + protected String getSessionDescriptorType() { + return AviatorUserSessionHelper.instance().getType(); + } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java index 5c4a22e79c3..647af22ffc0 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java @@ -12,20 +12,28 @@ */ package com.fortify.cli.common.session.cli.mixin; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.session.helper.ISessionDescriptor; import com.fortify.cli.common.session.helper.ISessionDescriptorSupplier; public abstract class AbstractSessionDescriptorSupplierMixin implements ISessionNameSupplier, ISessionDescriptorSupplier { public final D getSessionDescriptor() { - return getSessionDescriptor(getSessionName()); + var transientSessionDescriptor = getTransientSessionDescriptor(); + return transientSessionDescriptor != null ? transientSessionDescriptor : getSessionDescriptor(getSessionName()); } public final String getSessionName() { var sessionNameSupplier = getSessionNameSupplier(); return sessionNameSupplier==null?"default":sessionNameSupplier.getSessionName(); } + + @SuppressWarnings("unchecked") + private D getTransientSessionDescriptor() { + return (D)FcliExecutionContextHolder.current().getTransientSessionDescriptor(getSessionDescriptorType()); + } public abstract ISessionNameSupplier getSessionNameSupplier(); + protected abstract String getSessionDescriptorType(); protected abstract D getSessionDescriptor(String sessionName); } diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java new file mode 100644 index 00000000000..5ca9f44a56e --- /dev/null +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.session.cli.mixin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.Date; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.session.helper.ISessionDescriptor; + +class AbstractSessionDescriptorSupplierMixinTest { + @Test + void transientSessionDescriptorIsPreferredOverPersistedLookup() { + var supplier = new DummySessionDescriptorSupplier(); + var transientDescriptor = new DummySessionDescriptor("transient"); + FcliExecutionContextHolder.pushNew(); + try { + FcliExecutionContextHolder.current().setTransientSessionDescriptor(transientDescriptor); + + var result = supplier.getSessionDescriptor(); + + assertSame(transientDescriptor, result); + assertEquals(0, supplier.lookupCount); + } finally { + FcliExecutionContextHolder.pop(); + } + } + + @Test + void persistedLookupIsUsedIfNoTransientDescriptorExistsForType() { + var supplier = new DummySessionDescriptorSupplier(); + FcliExecutionContextHolder.pushNew(); + try { + FcliExecutionContextHolder.current().setTransientSessionDescriptor(new OtherSessionDescriptor()); + + var result = supplier.getSessionDescriptor(); + + assertEquals("persisted", result.value); + assertEquals(1, supplier.lookupCount); + } finally { + FcliExecutionContextHolder.pop(); + } + } + + private static final class DummySessionDescriptorSupplier extends AbstractSessionDescriptorSupplierMixin { + private int lookupCount; + + @Override + public ISessionNameSupplier getSessionNameSupplier() { + return () -> "default"; + } + + @Override + protected String getSessionDescriptorType() { + return "dummy"; + } + + @Override + protected DummySessionDescriptor getSessionDescriptor(String sessionName) { + lookupCount++; + return new DummySessionDescriptor("persisted"); + } + } + + private static final class DummySessionDescriptor implements ISessionDescriptor { + private final String value; + + private DummySessionDescriptor(String value) { + this.value = value; + } + + @Override + public String getUrlDescriptor() { + return value; + } + + @Override + public Date getCreatedDate() { + return new Date(); + } + + @Override + public Date getExpiryDate() { + return null; + } + + @Override + public String getType() { + return "dummy"; + } + } + + private static final class OtherSessionDescriptor implements ISessionDescriptor { + @Override + public String getUrlDescriptor() { + return "other"; + } + + @Override + public Date getCreatedDate() { + return new Date(); + } + + @Override + public Date getExpiryDate() { + return null; + } + + @Override + public String getType() { + return "other"; + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDUnirestInstanceSupplierMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDUnirestInstanceSupplierMixin.java index 8a0c128a225..ee0985ba452 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDUnirestInstanceSupplierMixin.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDUnirestInstanceSupplierMixin.java @@ -43,6 +43,11 @@ public final class FoDUnirestInstanceSupplierMixin extends AbstractSessionDescri protected final FoDSessionDescriptor getSessionDescriptor(String sessionName) { return FoDSessionHelper.instance().get(sessionName, true); } + + @Override + protected String getSessionDescriptorType() { + return FoDSessionHelper.instance().getType(); + } @Override public UnirestInstance getUnirestInstance() { diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/cli/mixin/SSCAndScanCentralUnirestInstanceSupplierMixin.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/cli/mixin/SSCAndScanCentralUnirestInstanceSupplierMixin.java index 24d3e02997b..bb2c7d0b1d1 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/cli/mixin/SSCAndScanCentralUnirestInstanceSupplierMixin.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/cli/mixin/SSCAndScanCentralUnirestInstanceSupplierMixin.java @@ -33,6 +33,11 @@ public class SSCAndScanCentralUnirestInstanceSupplierMixin extends AbstractSessi protected final SSCAndScanCentralSessionDescriptor getSessionDescriptor(String sessionName) { return SSCAndScanCentralSessionHelper.instance().get(sessionName, true); } + + @Override + protected String getSessionDescriptorType() { + return SSCAndScanCentralSessionHelper.instance().getType(); + } public final UnirestInstance getSscUnirestInstance() { return unirestContextMixin.getUnirestInstance("ssc/"+getSessionName(), From 2cb0b83d297ae4bd101bf91a256798a66fac30f8 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 6 May 2026 17:14:03 +0200 Subject: [PATCH 04/55] chore: Add HTTP MCP Server config --- .../helper/http/MCPServerHttpConfig.java | 131 ++++++++++++++++++ .../http/MCPServerHttpConfigLoader.java | 89 ++++++++++++ .../unit/MCPServerHttpConfigLoaderTest.java | 79 +++++++++++ 3 files changed, 299 insertions(+) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfigLoader.java create mode 100644 fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java new file mode 100644 index 00000000000..643f3f1032f --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java @@ -0,0 +1,131 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.helper.http; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.util._common.helper.AsyncJobManager; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data @NoArgsConstructor @Reflectable +@JsonIgnoreProperties(ignoreUnknown = true) +public class MCPServerHttpConfig { + private int port = 8080; + private int workThreads = 10; + private int progressThreads = 4; + private int asyncBgThreads = AsyncJobManager.DEFAULT_BG_THREADS; + private String jobSafeReturn = "25s"; + private String progressInterval = "5s"; + private Product product; + private List imports = new ArrayList<>(); + private SscConfig ssc; + private FoDConfig fod; + + @JsonIgnore private Path configPath; + + public enum Product { + ssc, + fod + } + + @Data @NoArgsConstructor @Reflectable + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SscConfig { + private String url; + private String scSastClientAuthToken; + } + + @Data @NoArgsConstructor @Reflectable + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FoDConfig { + private String url; + } + + public void validate(Path configPath) { + this.configPath = configPath; + if ( product == null ) { + throw new FcliSimpleException("HTTP MCP config must specify product: ssc|fod"); + } + if ( imports == null || imports.isEmpty() ) { + throw new FcliSimpleException("HTTP MCP config must specify at least one imports entry"); + } + imports.forEach(this::validateImportPath); + switch ( product ) { + case ssc -> validateSscConfig(); + case fod -> validateFoDConfig(); + default -> throw new FcliSimpleException("Unsupported HTTP MCP product: " + product); + } + } + + @JsonIgnore + public List getResolvedImportPaths() { + if ( configPath == null ) { + throw new IllegalStateException("Config path has not been set; validate() must be called first"); + } + return imports.stream() + .map(this::resolveImportPath) + .toList(); + } + + private void validateImportPath(String importPath) { + if ( StringUtils.isBlank(importPath) ) { + throw new FcliSimpleException("HTTP MCP config imports entries must not be blank"); + } + var resolvedPath = resolveImportPath(importPath); + if ( !resolvedPath.toFile().isFile() ) { + throw new FcliSimpleException("HTTP MCP import file not found: " + resolvedPath); + } + } + + private Path resolveImportPath(String importPath) { + var path = Path.of(importPath); + if ( path.isAbsolute() ) { + return path.normalize(); + } + return configPath.getParent().resolve(path).normalize(); + } + + private void validateSscConfig() { + if ( ssc == null ) { + throw new FcliSimpleException("HTTP MCP config product 'ssc' requires an ssc section"); + } + if ( fod != null ) { + throw new FcliSimpleException("HTTP MCP config product 'ssc' does not allow a fod section"); + } + if ( StringUtils.isBlank(ssc.getUrl()) ) { + throw new FcliSimpleException("HTTP MCP config ssc.url must be specified"); + } + } + + private void validateFoDConfig() { + if ( fod == null ) { + throw new FcliSimpleException("HTTP MCP config product 'fod' requires a fod section"); + } + if ( ssc != null ) { + throw new FcliSimpleException("HTTP MCP config product 'fod' does not allow an ssc section"); + } + if ( StringUtils.isBlank(fod.getUrl()) ) { + throw new FcliSimpleException("HTTP MCP config fod.url must be specified"); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfigLoader.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfigLoader.java new file mode 100644 index 00000000000..b365c734da9 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfigLoader.java @@ -0,0 +1,89 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.helper.http; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fortify.cli.common.action.runner.ActionSpelFunctions; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.spel.SpelEvaluator; +import com.fortify.cli.common.spel.SpelHelper; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public final class MCPServerHttpConfigLoader { + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + public static MCPServerHttpConfig load(Path configPath) { + if ( configPath == null ) { + throw new FcliSimpleException("HTTP MCP config path must be specified"); + } + var normalizedPath = configPath.toAbsolutePath().normalize(); + if ( !Files.isRegularFile(normalizedPath) ) { + throw new FcliSimpleException("HTTP MCP config file not found: " + normalizedPath); + } + try { + var rawNode = YAML_MAPPER.readTree(normalizedPath.toFile()); + var resolvedNode = resolveTemplateExpressions(rawNode); + var result = YAML_MAPPER.treeToValue(resolvedNode, MCPServerHttpConfig.class); + result.validate(normalizedPath); + return result; + } catch (FcliSimpleException e) { + throw e; + } catch (IOException e) { + throw new FcliSimpleException("Unable to read HTTP MCP config file: " + normalizedPath); + } + } + + private static JsonNode resolveTemplateExpressions(JsonNode node) { + if ( node == null || node.isNull() ) { + return NullNode.getInstance(); + } + if ( node.isObject() ) { + var objectNode = (ObjectNode)node; + var result = JsonHelper.getObjectMapper().createObjectNode(); + objectNode.properties().forEach(e -> result.set(e.getKey(), resolveTemplateExpressions(e.getValue()))); + return result; + } + if ( node.isArray() ) { + ArrayNode result = JsonHelper.getObjectMapper().createArrayNode(); + node.forEach(v -> result.add(resolveTemplateExpressions(v))); + return result; + } + if ( node.isTextual() ) { + return resolveTemplateExpression(node.asText()); + } + return node.deepCopy(); + } + + private static JsonNode resolveTemplateExpression(String value) { + if ( value == null || !value.contains("${") ) { + return value == null ? NullNode.getInstance() : new TextNode(value); + } + var evaluator = SpelEvaluator.JSON_GENERIC.copy() + .configure(ctx -> SpelHelper.registerFunctions(ctx, ActionSpelFunctions.class)); + var result = evaluator.evaluate(SpelHelper.parseTemplateExpression(value), null, Object.class); + return result == null ? NullNode.getInstance() : JsonHelper.getObjectMapper().valueToTree(result); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java new file mode 100644 index 00000000000..d73df2224d8 --- /dev/null +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.util.EnvHelper; +import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfig; +import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfigLoader; + +class MCPServerHttpConfigLoaderTest { + @TempDir Path tempDir; + + @Test + void loadResolvesRelativeImportsAndTemplateExpressionsForSscConfig() throws Exception { + var importsDir = Files.createDirectories(tempDir.resolve("imports")); + var importFile = importsDir.resolve("ssc-actions.yaml"); + Files.writeString(importFile, "functions: {}\n"); + var configFile = tempDir.resolve("mcp-http.yaml"); + var envProperty = EnvHelper.envSystemPropertyName("TEST_SCSAST_TOKEN"); + System.setProperty(envProperty, "secret-token"); + try { + Files.writeString(configFile, """ + product: ssc + imports: + - imports/ssc-actions.yaml + ssc: + url: https://ssc.example.com + scSastClientAuthToken: ${#env('TEST_SCSAST_TOKEN')} + """); + + MCPServerHttpConfig config = MCPServerHttpConfigLoader.load(configFile); + + assertEquals(MCPServerHttpConfig.Product.ssc, config.getProduct()); + assertEquals(8080, config.getPort()); + assertEquals("https://ssc.example.com", config.getSsc().getUrl()); + assertEquals("secret-token", config.getSsc().getScSastClientAuthToken()); + assertEquals(importFile.toAbsolutePath().normalize(), config.getResolvedImportPaths().get(0)); + } finally { + System.clearProperty(envProperty); + } + } + + @Test + void loadFailsIfConfiguredProductSectionDoesNotMatchSelectedProduct() throws Exception { + var importFile = tempDir.resolve("fod-actions.yaml"); + Files.writeString(importFile, "functions: {}\n"); + var configFile = tempDir.resolve("mcp-http.yaml"); + Files.writeString(configFile, """ + product: fod + imports: + - fod-actions.yaml + ssc: + url: https://ssc.example.com + """); + + var exception = assertThrows(FcliSimpleException.class, () -> MCPServerHttpConfigLoader.load(configFile)); + + assertEquals("HTTP MCP config product 'fod' requires a fod section", exception.getMessage()); + } +} \ No newline at end of file From 5cb6e917ba28e8f42966d1462f4148879ecb6886 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 6 May 2026 18:59:35 +0200 Subject: [PATCH 05/55] chore: Implement MCP server (incomplete) --- .../mcp_server/cli/cmd/MCPServerCommands.java | 3 +- .../cli/cmd/MCPServerStartHttpCommand.java | 129 ++++++++++++++ .../JdkHttpServerMcpStatelessTransport.java | 168 ++++++++++++++++++ .../helper/http/MCPServerHttpConfig.java | 27 ++- .../mcp/MCPImportedActionMcpSpecsFactory.java | 158 ++++++++++++++++ .../cli/util/i18n/UtilMessages.properties | 19 ++ .../unit/MCPServerHttpConfigLoaderTest.java | 8 +- 7 files changed, 491 insertions(+), 21 deletions(-) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPImportedActionMcpSpecsFactory.java diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerCommands.java index 1342fc16165..4a33c88249e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerCommands.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerCommands.java @@ -19,7 +19,8 @@ @Command( name = "mcp-server", subcommands = { - MCPServerStartCommand.class + MCPServerStartCommand.class, + MCPServerStartHttpCommand.class } ) public class MCPServerCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java new file mode 100644 index 00000000000..d44fc7ef684 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java @@ -0,0 +1,129 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.cli.cmd; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.mcp.MCPExclude; +import com.fortify.cli.common.util.DateTimePeriodHelper; +import com.fortify.cli.common.util.DateTimePeriodHelper.Period; +import com.fortify.cli.common.util.FcliBuildProperties; +import com.fortify.cli.util._common.helper.AsyncJobManager; +import com.fortify.cli.util.mcp_server.helper.http.JdkHttpServerMcpStatelessTransport; +import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfigLoader; +import com.fortify.cli.util.mcp_server.helper.mcp.MCPImportedActionMcpSpecsFactory; +import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; + +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "start-http") +@MCPExclude +@Slf4j +public class MCPServerStartHttpCommand extends AbstractRunnableCommand { + private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); + + @Option(names = {"--config"}, required = true) + private Path configPath; + + @Override + public Integer call() throws Exception { + var config = MCPServerHttpConfigLoader.load(configPath); + + var safeReturnMillis = PERIOD_HELPER.parsePeriodToMillis(config.getJobSafeReturn()); + var progressIntervalMillis = PERIOD_HELPER.parsePeriodToMillis(config.getProgressInterval()); + if ( safeReturnMillis <= 0 ) { + safeReturnMillis = 25000; + } + if ( progressIntervalMillis <= 0 ) { + progressIntervalMillis = 500; + } + + var asyncJobManager = new AsyncJobManager(AsyncJobManager.Config.builder().bgThreads(config.getAsyncBgThreads()).build()); + var jobManager = new MCPJobManager( + config.getWorkThreads(), + config.getProgressThreads(), + safeReturnMillis, + progressIntervalMillis, + asyncJobManager + ); + + var sharedFunctionContext = new FcliExecutionContext(); + var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, sharedFunctionContext); + var toolSpecs = new ArrayList(); + var resourceTemplateSpecs = new ArrayList(); + for ( var importPath : config.getResolvedImportPaths() ) { + var importedSpecs = importSpecsFactory.create(importPath); + toolSpecs.addAll(importedSpecs.tools()); + resourceTemplateSpecs.addAll(importedSpecs.resourceTemplates()); + } + var jobToolSpec = jobManager.getJobToolSpecification(); + toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() + .tool(jobToolSpec.tool()) + .callHandler((ctx, request) -> jobToolSpec.callHandler().apply(null, request)) + .build()); + + if ( toolSpecs.size() == 1 ) { + throw new FcliSimpleException("HTTP MCP config imports did not produce any exported functions"); + } + + var objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + var transport = new JdkHttpServerMcpStatelessTransport(config.getPort(), "/mcp", new JacksonMcpJsonMapper(objectMapper)); + + var serverBuilder = McpServer.sync(transport) + .serverInfo("fcli", FcliBuildProperties.INSTANCE.getFcliVersion()) + .requestTimeout(Duration.ofSeconds(120)) + .instructions("HTTP MCP server exposing imported fcli action functions") + .capabilities(getServerCapabilities(!resourceTemplateSpecs.isEmpty())) + .tools(toolSpecs); + if ( !resourceTemplateSpecs.isEmpty() ) { + serverBuilder.resourceTemplates(resourceTemplateSpecs); + } + var mcpServer = serverBuilder.build(); + log.debug("Initialized HTTP MCP server instance: {}", mcpServer); + + transport.start(); + log.info("Fcli HTTP MCP server running on port {} for product {}", config.getPort(), config.getProduct()); + System.err.println("Fcli HTTP MCP server running on port " + config.getPort() + " endpoint /mcp. Hit Ctrl-C to exit."); + + var latch = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + transport.close(); + asyncJobManager.shutdown(); + latch.countDown(); + }, "mcp-http-shutdown-hook")); + latch.await(); + return 0; + } + + private static ServerCapabilities getServerCapabilities(boolean hasResources) { + return ServerCapabilities.builder() + .resources(hasResources, false) + .prompts(false) + .tools(true) + .build(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java new file mode 100644 index 00000000000..57b383ad609 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -0,0 +1,168 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.helper.http; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpStatelessServerHandler; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpStatelessServerTransport; +import reactor.core.publisher.Mono; + +/** + * JDK {@link HttpServer}-based MCP stateless transport implementation. + */ +public class JdkHttpServerMcpStatelessTransport implements McpStatelessServerTransport { + private static final String APPLICATION_JSON = "application/json"; + private static final String TEXT_EVENT_STREAM = "text/event-stream"; + + private final HttpServer httpServer; + private final String mcpEndpoint; + private final McpJsonMapper jsonMapper; + private volatile McpStatelessServerHandler mcpHandler; + private volatile boolean closing; + + public JdkHttpServerMcpStatelessTransport(int port, String mcpEndpoint, McpJsonMapper jsonMapper) throws IOException { + this.httpServer = HttpServer.create(new InetSocketAddress(port), 0); + this.mcpEndpoint = normalizeEndpoint(mcpEndpoint); + this.jsonMapper = jsonMapper; + this.httpServer.createContext(this.mcpEndpoint, this::handleExchange); + } + + public void start() { + httpServer.start(); + } + + @Override + public void setMcpHandler(McpStatelessServerHandler mcpHandler) { + this.mcpHandler = mcpHandler; + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> { + closing = true; + httpServer.stop(1); + }); + } + + private void handleExchange(HttpExchange exchange) throws IOException { + if ( closing ) { + sendPlainError(exchange, 503, "Server is shutting down"); + return; + } + if ( !exchange.getRequestURI().getPath().equals(mcpEndpoint) ) { + sendPlainError(exchange, 404, "Not found"); + return; + } + if ( !"POST".equalsIgnoreCase(exchange.getRequestMethod()) ) { + sendPlainError(exchange, 405, "Method not allowed"); + return; + } + if ( mcpHandler == null ) { + sendPlainError(exchange, 503, "MCP handler not initialized"); + return; + } + + var accept = getFirstHeader(exchange, "Accept"); + if ( accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM)) ) { + sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Both application/json and text/event-stream required in Accept header") + .build()); + return; + } + + var transportContext = McpTransportContext.create(Map.of( + "method", exchange.getRequestMethod(), + "path", exchange.getRequestURI().getPath(), + "headers", exchange.getRequestHeaders().entrySet().stream() + .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, e -> List.copyOf(e.getValue()))))); + try { + var body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + var message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); + if ( message instanceof McpSchema.JSONRPCRequest request ) { + var response = mcpHandler.handleRequest(transportContext, request) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + sendJson(exchange, 200, response); + } else if ( message instanceof McpSchema.JSONRPCNotification notification ) { + mcpHandler.handleNotification(transportContext, notification) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + sendEmpty(exchange, 202); + } else { + sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("The server accepts either requests or notifications") + .build()); + } + } catch (IllegalArgumentException e) { + sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Invalid message format") + .build()); + } catch (Exception e) { + sendMcpError(exchange, 500, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Unexpected error: " + e.getMessage()) + .build()); + } + } + + private String normalizeEndpoint(String endpoint) { + if ( endpoint == null || endpoint.isBlank() ) { + return "/mcp"; + } + return endpoint.startsWith("/") ? endpoint : "/" + endpoint; + } + + private String getFirstHeader(HttpExchange exchange, String name) { + var values = exchange.getRequestHeaders().get(name); + return values == null || values.isEmpty() ? null : values.get(0); + } + + private void sendPlainError(HttpExchange exchange, int status, String message) throws IOException { + var bytes = message.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + try ( var outputStream = exchange.getResponseBody() ) { + outputStream.write(bytes); + } + } + + private void sendMcpError(HttpExchange exchange, int status, McpError error) throws IOException { + sendJson(exchange, status, error); + } + + private void sendJson(HttpExchange exchange, int status, Object payload) throws IOException { + var bytes = jsonMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + try ( var outputStream = exchange.getResponseBody() ) { + outputStream.write(bytes); + } + } + + private void sendEmpty(HttpExchange exchange, int status) throws IOException { + exchange.sendResponseHeaders(status, -1); + exchange.close(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java index 643f3f1032f..29c2fef4261 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java @@ -36,7 +36,6 @@ public class MCPServerHttpConfig { private int asyncBgThreads = AsyncJobManager.DEFAULT_BG_THREADS; private String jobSafeReturn = "25s"; private String progressInterval = "5s"; - private Product product; private List imports = new ArrayList<>(); private SscConfig ssc; private FoDConfig fod; @@ -63,20 +62,26 @@ public static class FoDConfig { public void validate(Path configPath) { this.configPath = configPath; - if ( product == null ) { - throw new FcliSimpleException("HTTP MCP config must specify product: ssc|fod"); - } if ( imports == null || imports.isEmpty() ) { throw new FcliSimpleException("HTTP MCP config must specify at least one imports entry"); } imports.forEach(this::validateImportPath); - switch ( product ) { + switch ( getProduct() ) { case ssc -> validateSscConfig(); case fod -> validateFoDConfig(); - default -> throw new FcliSimpleException("Unsupported HTTP MCP product: " + product); } } + @JsonIgnore + public Product getProduct() { + var hasSsc = ssc != null; + var hasFod = fod != null; + if ( hasSsc == hasFod ) { + throw new FcliSimpleException("HTTP MCP config must specify exactly one of ssc or fod section"); + } + return hasSsc ? Product.ssc : Product.fod; + } + @JsonIgnore public List getResolvedImportPaths() { if ( configPath == null ) { @@ -106,11 +111,8 @@ private Path resolveImportPath(String importPath) { } private void validateSscConfig() { - if ( ssc == null ) { - throw new FcliSimpleException("HTTP MCP config product 'ssc' requires an ssc section"); - } if ( fod != null ) { - throw new FcliSimpleException("HTTP MCP config product 'ssc' does not allow a fod section"); + throw new FcliSimpleException("HTTP MCP config must not specify both ssc and fod sections"); } if ( StringUtils.isBlank(ssc.getUrl()) ) { throw new FcliSimpleException("HTTP MCP config ssc.url must be specified"); @@ -118,11 +120,8 @@ private void validateSscConfig() { } private void validateFoDConfig() { - if ( fod == null ) { - throw new FcliSimpleException("HTTP MCP config product 'fod' requires a fod section"); - } if ( ssc != null ) { - throw new FcliSimpleException("HTTP MCP config product 'fod' does not allow an ssc section"); + throw new FcliSimpleException("HTTP MCP config must not specify both ssc and fod sections"); } if ( StringUtils.isBlank(fod.getUrl()) ) { throw new FcliSimpleException("HTTP MCP config fod.url must be specified"); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPImportedActionMcpSpecsFactory.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPImportedActionMcpSpecsFactory.java new file mode 100644 index 00000000000..3c4936833f7 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPImportedActionMcpSpecsFactory.java @@ -0,0 +1,158 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.helper.mcp; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.common.action.helper.ActionLoaderHelper; +import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; +import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; +import com.fortify.cli.common.action.model.ActionFunction; +import com.fortify.cli.common.action.runner.ActionFunctionExecutor; +import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPResourceFcliRunnerFunction; +import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerFunction; +import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerFunctionStreaming; + +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.spec.McpSchema.JsonSchema; +import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class MCPImportedActionMcpSpecsFactory { + private final MCPJobManager jobManager; + private final FcliExecutionContext sharedFunctionContext; + + public ImportedSpecs create(Path importFile) { + var action = ActionLoaderHelper.load( + ActionSource.externalActionSources(importFile.toString()), + importFile.toString(), + ActionValidationHandler.WARN + ).getAction(); + var tools = new ArrayList(); + var resourceTemplates = new ArrayList(); + for ( var entry : action.getFunctions().entrySet() ) { + var functionName = entry.getKey(); + var function = entry.getValue(); + if ( !function.isExported() ) { + continue; + } + if ( hasMcpResourceMeta(function) ) { + resourceTemplates.add(createResourceTemplateSpec(action, functionName, function)); + } else { + tools.add(createToolSpec(action, functionName, function)); + } + } + return new ImportedSpecs(tools, resourceTemplates); + } + + private SyncToolSpecification createToolSpec(com.fortify.cli.common.action.model.Action action, String functionName, ActionFunction function) { + var executor = new ActionFunctionExecutor(action, function, sharedFunctionContext); + var toolName = "fcli_fn_" + functionName.replace('-', '_'); + var schema = buildFunctionArgsSchema(function); + var description = function.getDescription() != null ? function.getDescription() : functionName; + var runner = function.isStreaming() + ? createStreamingRunner(executor, toolName, schema) + : new MCPToolFcliRunnerFunction(executor, jobManager, toolName); + var tool = Tool.builder() + .name(toolName) + .description(description) + .inputSchema(schema) + .build(); + return McpStatelessServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler((ctx, request) -> runner.run(null, request)) + .build(); + } + + private MCPToolFcliRunnerFunctionStreaming createStreamingRunner(ActionFunctionExecutor executor, String toolName, JsonSchema schema) { + new MCPToolArgHandlerPaging().updateSchema(schema); + return new MCPToolFcliRunnerFunctionStreaming(executor, jobManager, toolName); + } + + private SyncResourceTemplateSpecification createResourceTemplateSpec(com.fortify.cli.common.action.model.Action action, + String functionName, ActionFunction function) + { + var resourceMeta = function.getMeta().get("mcp.resource"); + var uriTemplate = getMetaString(resourceMeta, "uri-template"); + var name = getMetaString(resourceMeta, "name"); + var mimeType = getMetaString(resourceMeta, "mime-type"); + var executor = new ActionFunctionExecutor(action, function, sharedFunctionContext); + var template = ResourceTemplate.builder() + .uriTemplate(uriTemplate) + .name(name != null ? name : functionName) + .description(function.getDescription()) + .mimeType(mimeType != null ? mimeType : "application/json") + .build(); + var handler = new MCPResourceFcliRunnerFunction(executor, uriTemplate, mimeType); + return new SyncResourceTemplateSpecification(template, (ctx, request) -> handler.read(null, request)); + } + + private JsonSchema buildFunctionArgsSchema(ActionFunction function) { + var properties = new LinkedHashMap(); + var required = new ArrayList(); + for ( var argEntry : function.getArgsOrEmpty().entrySet() ) { + var argName = argEntry.getKey(); + var argDef = argEntry.getValue(); + var property = new LinkedHashMap(); + property.put("type", mapArgType(argDef.getType())); + if ( argDef.getDescription() != null ) { + property.put("description", argDef.getDescription()); + } + properties.put(argName, property); + if ( Boolean.TRUE.equals(argDef.getRequired()) ) { + required.add(argName); + } + } + return new JsonSchema("object", properties, required, false, + new LinkedHashMap<>(), new LinkedHashMap<>()); + } + + private String mapArgType(String type) { + if ( type == null ) { + return "string"; + } + return switch (type) { + case "boolean" -> "boolean"; + case "int", "long" -> "integer"; + case "double", "float" -> "number"; + case "array" -> "string"; + default -> "string"; + }; + } + + private boolean hasMcpResourceMeta(ActionFunction function) { + return function.getMeta() != null && function.getMeta().has("mcp.resource"); + } + + private String getMetaString(JsonNode meta, String key) { + if ( meta == null || !meta.has(key) ) { + return null; + } + var node = meta.get(key); + return node.isTextual() ? node.asText() : null; + } + + public record ImportedSpecs( + java.util.List tools, + java.util.List resourceTemplates + ) {} +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 9ce6b568038..4d3622006be 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -105,6 +105,25 @@ fcli.util.mcp-server.start.progress-threads = Number of threads used for updatin fcli.util.mcp-server.start.job-safe-return = Maximum time to wait synchronously for a job result before returning an in_progress placeholder. Specify duration like 25s, 2m, 1h. fcli.util.mcp-server.start.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). fcli.util.mcp-server.start.async-bg-threads = Number of background threads for running async streaming jobs. Default: 2. +fcli.util.mcp-server.start-http.usage.header = (PREVIEW) Start import-only HTTP fcli MCP server for LLM integration +fcli.util.mcp-server.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. This command uses an embedded JDK HTTP server and serves MCP requests on /mcp. Although the Java MCP SDK provides built-in HTTP transports in the core module, those transports are servlet-based and still require a servlet container/runtime; fcli uses JDK HttpServer to avoid adding servlet/container dependencies and to reduce native-image integration risk.\ + %n%nCONFIG FILE STRUCTURE (YAML):\ + %nSpecify exactly one of the 'ssc' or 'fod' sections; product is inferred from section presence.\ + %n%nSSC example:\ + %nport: 8080\ + %nimports:\ + %n - actions/http-ssc.yaml\ + %nssc:\ + %n url: https://ssc.example.com\ + %n scSastClientAuthToken: ${#env('SSC_SAST_CLIENT_AUTH_TOKEN')}\ + %n%nFoD example:\ + %nport: 8080\ + %nimports:\ + %n - actions/http-fod.yaml\ + %nfod:\ + %n url: https://api.ams.fortify.com\ + %n%nOptional runtime settings (with defaults): workThreads=10, progressThreads=4, asyncBgThreads=2, jobSafeReturn=25s, progressInterval=5s +fcli.util.mcp-server.start-http.config = Path to HTTP MCP YAML config file. # fcli util rpc-server fcli.util.rpc-server.usage.header = (PREVIEW) Manage fcli JSON-RPC server for IDE plugin integration diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java index d73df2224d8..52b5b6c8738 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java @@ -39,7 +39,6 @@ void loadResolvesRelativeImportsAndTemplateExpressionsForSscConfig() throws Exce System.setProperty(envProperty, "secret-token"); try { Files.writeString(configFile, """ - product: ssc imports: - imports/ssc-actions.yaml ssc: @@ -60,20 +59,17 @@ void loadResolvesRelativeImportsAndTemplateExpressionsForSscConfig() throws Exce } @Test - void loadFailsIfConfiguredProductSectionDoesNotMatchSelectedProduct() throws Exception { + void loadFailsIfNoProductSectionIsSpecified() throws Exception { var importFile = tempDir.resolve("fod-actions.yaml"); Files.writeString(importFile, "functions: {}\n"); var configFile = tempDir.resolve("mcp-http.yaml"); Files.writeString(configFile, """ - product: fod imports: - fod-actions.yaml - ssc: - url: https://ssc.example.com """); var exception = assertThrows(FcliSimpleException.class, () -> MCPServerHttpConfigLoader.load(configFile)); - assertEquals("HTTP MCP config product 'fod' requires a fod section", exception.getMessage()); + assertEquals("HTTP MCP config must specify exactly one of ssc or fod section", exception.getMessage()); } } \ No newline at end of file From 60667e38bdcfaa65b9ad199d4d7cf5326a2e3f84 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 8 May 2026 10:16:39 +0200 Subject: [PATCH 06/55] chore: Implement HTTP MCP server auth handling --- .../cli/util/FcliExecutionContextHolder.java | 16 + ...bstractSessionDescriptorSupplierMixin.java | 2 +- ...actSessionDescriptorSupplierMixinTest.java | 21 ++ fcli-core/fcli-util/build.gradle.kts | 6 + .../cli/cmd/MCPServerStartHttpCommand.java | 295 +++++++++++++++++- .../helper/http/MCPServerHttpConfig.java | 51 ++- .../mcp/runner/MCPToolAsyncJobManager.java | 3 + .../cli/util/i18n/UtilMessages.properties | 13 +- 8 files changed, 399 insertions(+), 8 deletions(-) diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java index 4d382dd8345..9953fa35578 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java @@ -15,6 +15,8 @@ import java.util.ArrayDeque; import java.util.Deque; +import com.fortify.cli.common.session.helper.ISessionDescriptor; + /** * Explicit holder for the current thread's execution context stack. * Use push()/pop() to manage nested execution contexts. No implicit @@ -53,6 +55,20 @@ public static FcliExecutionContext current() { if ( stack.isEmpty() ) { stack.push(new FcliExecutionContext()); } return stack.peek(); } + + /** + * Look up a transient session descriptor by type, searching from top to bottom + * through the current thread's execution-context stack. + */ + public static ISessionDescriptor getTransientSessionDescriptor(String type) { + for ( var context : HOLDER.get() ) { + var descriptor = context.getTransientSessionDescriptor(type); + if ( descriptor != null ) { + return descriptor; + } + } + return null; + } /** Return the current stack depth. Useful for logging/troubleshooting. */ public static int stackDepth() { return HOLDER.get().size(); } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java index 647af22ffc0..b38389439f9 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java @@ -29,7 +29,7 @@ public final String getSessionName() { @SuppressWarnings("unchecked") private D getTransientSessionDescriptor() { - return (D)FcliExecutionContextHolder.current().getTransientSessionDescriptor(getSessionDescriptorType()); + return (D)FcliExecutionContextHolder.getTransientSessionDescriptor(getSessionDescriptorType()); } public abstract ISessionNameSupplier getSessionNameSupplier(); diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java index 5ca9f44a56e..b525967f46b 100644 --- a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java @@ -56,6 +56,27 @@ void persistedLookupIsUsedIfNoTransientDescriptorExistsForType() { } } + @Test + void transientSessionDescriptorIsFoundInNestedParentContext() { + var supplier = new DummySessionDescriptorSupplier(); + var transientDescriptor = new DummySessionDescriptor("transient"); + FcliExecutionContextHolder.pushNew(); + try { + FcliExecutionContextHolder.current().setTransientSessionDescriptor(transientDescriptor); + FcliExecutionContextHolder.pushNew(); + try { + var result = supplier.getSessionDescriptor(); + + assertSame(transientDescriptor, result); + assertEquals(0, supplier.lookupCount); + } finally { + FcliExecutionContextHolder.pop(); + } + } finally { + FcliExecutionContextHolder.pop(); + } + } + private static final class DummySessionDescriptorSupplier extends AbstractSessionDescriptorSupplierMixin { private int lookupCount; diff --git a/fcli-core/fcli-util/build.gradle.kts b/fcli-core/fcli-util/build.gradle.kts index 06113e4af74..c1c815d0c49 100644 --- a/fcli-core/fcli-util/build.gradle.kts +++ b/fcli-core/fcli-util/build.gradle.kts @@ -1 +1,7 @@ plugins { id("fcli.module-conventions") } + +val refs = listOf("fcliFoDRef", "fcliSSCRef") +references@ for (r in refs) { + val p = project.findProperty(r) as String? ?: continue@references + dependencies.add("implementation", project(p)) +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java index d44fc7ef684..0e3cd6ac050 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java @@ -15,23 +15,48 @@ import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; +import java.util.function.Supplier; + +import org.apache.commons.lang3.StringUtils; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.mcp.MCPExclude; +import com.fortify.cli.common.rest.unirest.config.UrlConfig; +import com.fortify.cli.common.session.helper.ISessionDescriptor; import com.fortify.cli.common.util.DateTimePeriodHelper; import com.fortify.cli.common.util.DateTimePeriodHelper.Period; import com.fortify.cli.common.util.FcliBuildProperties; +import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; +import com.fortify.cli.fod._common.session.helper.FoDSessionDescriptor; +import com.fortify.cli.fod._common.session.helper.oauth.FoDOAuthHelper; +import com.fortify.cli.fod._common.session.helper.oauth.FoDTokenCreateResponse; +import com.fortify.cli.fod._common.session.helper.oauth.IFoDClientCredentials; +import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials; +import com.fortify.cli.ssc._common.session.cli.mixin.SSCAndScanCentralSessionLoginOptions.SSCAndScanCentralUrlConfigOptions.SSCComponentDisable; +import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralCredentialsConfig; +import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralUrlConfig; +import com.fortify.cli.ssc._common.session.helper.ISSCUserCredentialsConfig; +import com.fortify.cli.ssc._common.session.helper.SSCAndScanCentralSessionDescriptor; +import com.fortify.cli.ssc.access_control.helper.SSCTokenGetOrCreateResponse.SSCTokenData; import com.fortify.cli.util._common.helper.AsyncJobManager; import com.fortify.cli.util.mcp_server.helper.http.JdkHttpServerMcpStatelessTransport; +import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfig; +import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfig.Product; import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfigLoader; import com.fortify.cli.util.mcp_server.helper.mcp.MCPImportedActionMcpSpecsFactory; import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpStatelessServerFeatures; @@ -45,6 +70,14 @@ @Slf4j public class MCPServerStartHttpCommand extends AbstractRunnableCommand { private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); + private static final String HEADER_SSC_TOKEN = "X-FCLI-SSC-TOKEN"; + private static final String HEADER_SC_SAST_CLIENT_AUTH_TOKEN = "X-FCLI-SC-SAST-CLIENT-AUTH-TOKEN"; + private static final String HEADER_FOD_TENANT = "X-FCLI-FOD-TENANT"; + private static final String HEADER_FOD_USER = "X-FCLI-FOD-USER"; + private static final String HEADER_FOD_PAT = "X-FCLI-FOD-PAT"; + private static final String HEADER_FOD_CLIENT_ID = "X-FCLI-FOD-CLIENT-ID"; + private static final String HEADER_FOD_CLIENT_SECRET = "X-FCLI-FOD-CLIENT-SECRET"; + private static final String[] DEFAULT_FOD_SCOPES = new String[] {"api-tenant"}; @Option(names = {"--config"}, required = true) private Path configPath; @@ -75,15 +108,26 @@ public Integer call() throws Exception { var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, sharedFunctionContext); var toolSpecs = new ArrayList(); var resourceTemplateSpecs = new ArrayList(); + var sessionDescriptorCache = new ConcurrentHashMap(); for ( var importPath : config.getResolvedImportPaths() ) { var importedSpecs = importSpecsFactory.create(importPath); - toolSpecs.addAll(importedSpecs.tools()); - resourceTemplateSpecs.addAll(importedSpecs.resourceTemplates()); + importedSpecs.tools().forEach(tool -> toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() + .tool(tool.tool()) + .callHandler((ctx, request) -> withRequestExecutionContext(ctx, config.getProduct(), sessionDescriptorCache, + () -> tool.callHandler().apply(ctx, request), config)) + .build())); + importedSpecs.resourceTemplates().forEach(resourceTemplate -> resourceTemplateSpecs.add( + new McpStatelessServerFeatures.SyncResourceTemplateSpecification( + resourceTemplate.resourceTemplate(), + (ctx, request) -> withRequestExecutionContext(ctx, config.getProduct(), sessionDescriptorCache, + () -> resourceTemplate.readHandler().apply(ctx, request), config) + ))); } var jobToolSpec = jobManager.getJobToolSpecification(); toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() .tool(jobToolSpec.tool()) - .callHandler((ctx, request) -> jobToolSpec.callHandler().apply(null, request)) + .callHandler((ctx, request) -> withRequestExecutionContext(ctx, config.getProduct(), sessionDescriptorCache, + () -> jobToolSpec.callHandler().apply(null, request), config)) .build()); if ( toolSpecs.size() == 1 ) { @@ -119,6 +163,251 @@ public Integer call() throws Exception { return 0; } + private T withRequestExecutionContext(McpTransportContext transportContext, Product product, + Map sessionDescriptorCache, Supplier supplier, + MCPServerHttpConfig config) + { + var executionContext = FcliExecutionContextHolder.pushNew(); + try { + executionContext.setTransientSessionDescriptor(getOrCreateSessionDescriptor(transportContext, product, sessionDescriptorCache, config)); + return supplier.get(); + } finally { + FcliExecutionContextHolder.pop(); + } + } + + private ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext transportContext, Product product, + Map sessionDescriptorCache, + MCPServerHttpConfig config) + { + var cacheKey = createAuthCacheKey(transportContext, product); + return sessionDescriptorCache.computeIfAbsent(cacheKey, k -> createSessionDescriptor(product, transportContext, config)); + } + + private String createAuthCacheKey(McpTransportContext transportContext, Product product) { + return switch (product) { + case ssc -> String.format("ssc|%s|%s", + getRequiredHeader(transportContext, HEADER_SSC_TOKEN), + StringUtils.defaultString(getOptionalHeader(transportContext, HEADER_SC_SAST_CLIENT_AUTH_TOKEN))); + case fod -> createFoDAuthCacheKey(transportContext); + }; + } + + private String createFoDAuthCacheKey(McpTransportContext transportContext) { + var clientId = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_ID); + var clientSecret = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_SECRET); + var tenant = getOptionalHeader(transportContext, HEADER_FOD_TENANT); + var user = getOptionalHeader(transportContext, HEADER_FOD_USER); + var pat = getOptionalHeader(transportContext, HEADER_FOD_PAT); + if ( StringUtils.isNotBlank(clientId) || StringUtils.isNotBlank(clientSecret) ) { + if ( StringUtils.isAnyBlank(clientId, clientSecret) ) { + throw new FcliSimpleException("FoD client authentication requires both %s and %s", HEADER_FOD_CLIENT_ID, HEADER_FOD_CLIENT_SECRET); + } + if ( StringUtils.isNotBlank(tenant) || StringUtils.isNotBlank(user) || StringUtils.isNotBlank(pat) ) { + throw new FcliSimpleException("Specify either FoD client headers (%s, %s) or FoD user headers (%s, %s, %s)", + HEADER_FOD_CLIENT_ID, HEADER_FOD_CLIENT_SECRET, + HEADER_FOD_TENANT, HEADER_FOD_USER, HEADER_FOD_PAT); + } + return "fod-client|" + clientId + "|" + clientSecret; + } + if ( StringUtils.isAnyBlank(tenant, user, pat) ) { + throw new FcliSimpleException("FoD user authentication requires headers %s, %s, and %s", + HEADER_FOD_TENANT, HEADER_FOD_USER, HEADER_FOD_PAT); + } + return "fod-user|" + tenant + "|" + user + "|" + pat; + } + + private ISessionDescriptor createSessionDescriptor(Product product, McpTransportContext transportContext, + MCPServerHttpConfig config) + { + return switch (product) { + case ssc -> createSscSessionDescriptor(transportContext, config); + case fod -> createFoDSessionDescriptor(transportContext, config); + }; + } + + private ISessionDescriptor createSscSessionDescriptor(McpTransportContext transportContext, MCPServerHttpConfig config) + { + var token = getRequiredHeader(transportContext, HEADER_SSC_TOKEN); + var tokenData = new SSCTokenData(); + tokenData.setToken(token.toCharArray()); + var sscConfig = config.getSsc(); + var scSastClientAuthToken = StringUtils.firstNonBlank( + sscConfig.getScSastClientAuthToken(), + getOptionalHeader(transportContext, HEADER_SC_SAST_CLIENT_AUTH_TOKEN) + ); + return SSCAndScanCentralSessionDescriptor.create( + new HttpMcpSscUrlConfig(sscConfig), + new HttpMcpSscCredentialsConfig(tokenData.getToken(), + StringUtils.isBlank(scSastClientAuthToken) ? null : scSastClientAuthToken.toCharArray()) + ); + } + + private ISessionDescriptor createFoDSessionDescriptor(McpTransportContext transportContext, MCPServerHttpConfig config) + { + var fodConfig = config.getFod(); + var urlConfig = UrlConfig.builderFromConnectionConfig(fodConfig) + .url(FoDProductHelper.INSTANCE.getApiUrl(fodConfig.getUrl())) + .build(); + var fodTokenResponse = createFoDTokenResponse(transportContext, urlConfig); + return new FoDSessionDescriptor(urlConfig, fodTokenResponse); + } + + private FoDTokenCreateResponse createFoDTokenResponse(McpTransportContext transportContext, UrlConfig urlConfig) { + var clientId = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_ID); + var clientSecret = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_SECRET); + if ( StringUtils.isNotBlank(clientId) || StringUtils.isNotBlank(clientSecret) ) { + return FoDOAuthHelper.createToken(urlConfig, + new HttpMcpFoDClientCredentials( + getRequiredHeader(transportContext, HEADER_FOD_CLIENT_ID), + getRequiredHeader(transportContext, HEADER_FOD_CLIENT_SECRET) + ), + DEFAULT_FOD_SCOPES + ); + } + return FoDOAuthHelper.createToken(urlConfig, + new HttpMcpFoDUserCredentials( + getRequiredHeader(transportContext, HEADER_FOD_TENANT), + getRequiredHeader(transportContext, HEADER_FOD_USER), + getRequiredHeader(transportContext, HEADER_FOD_PAT).toCharArray() + ), + DEFAULT_FOD_SCOPES + ); + } + + @SuppressWarnings("unchecked") + private String getOptionalHeader(McpTransportContext transportContext, String headerName) { + var headers = (Map>)transportContext.get("headers"); + if ( headers == null || headers.isEmpty() ) { + return null; + } + return headers.entrySet().stream() + .filter(e -> headerName.equalsIgnoreCase(e.getKey())) + .map(Map.Entry::getValue) + .filter(v -> v != null && !v.isEmpty()) + .map(v -> v.get(0)) + .map(StringUtils::trimToNull) + .findFirst().orElse(null); + } + + private String getRequiredHeader(McpTransportContext transportContext, String headerName) { + var value = getOptionalHeader(transportContext, headerName); + if ( StringUtils.isBlank(value) ) { + throw new FcliSimpleException("Missing required HTTP header: %s", headerName); + } + return value; + } + + private static final class HttpMcpFoDClientCredentials implements IFoDClientCredentials { + private final String clientId; + private final String clientSecret; + + private HttpMcpFoDClientCredentials(String clientId, String clientSecret) { + this.clientId = clientId; + this.clientSecret = clientSecret; + } + + @Override + public String getClientId() { + return clientId; + } + + @Override + public String getClientSecret() { + return clientSecret; + } + } + + private static final class HttpMcpFoDUserCredentials implements IFoDUserCredentials { + private final String tenant; + private final String user; + private final char[] password; + + private HttpMcpFoDUserCredentials(String tenant, String user, char[] password) { + this.tenant = tenant; + this.user = user; + this.password = password; + } + + @Override + public String getUser() { + return user; + } + + @Override + public char[] getPassword() { + return password; + } + + @Override + public String getTenant() { + return tenant; + } + } + + private static final class HttpMcpSscUrlConfig implements ISSCAndScanCentralUrlConfig { + private final MCPServerHttpConfig.SscConfig config; + + private HttpMcpSscUrlConfig(MCPServerHttpConfig.SscConfig config) { + this.config = config; + } + + @Override + public String getSscUrl() { + return config.getUrl(); + } + + @Override + public String getScSastControllerUrl() { + return null; + } + + @Override + public Set getDisabledComponents() { + return new HashSet<>(); + } + + @Override + public int getConnectTimeoutInMillis() { + return config.getConnectTimeoutInMillis(); + } + + @Override + public int getSocketTimeoutInMillis() { + return config.getSocketTimeoutInMillis(); + } + + @Override + public Boolean getInsecureModeEnabled() { + return config.getInsecureModeEnabled(); + } + } + + private static final class HttpMcpSscCredentialsConfig implements ISSCAndScanCentralCredentialsConfig { + private final char[] sscToken; + private final char[] scSastClientAuthToken; + + private HttpMcpSscCredentialsConfig(char[] sscToken, char[] scSastClientAuthToken) { + this.sscToken = sscToken; + this.scSastClientAuthToken = scSastClientAuthToken; + } + + @Override + public char[] getSscToken() { + return sscToken; + } + + @Override + public ISSCUserCredentialsConfig getSscUserCredentialsConfig() { + return null; + } + + @Override + public char[] getScSastClientAuthToken() { + return scSastClientAuthToken; + } + } + private static ServerCapabilities getServerCapabilities(boolean hasResources) { return ServerCapabilities.builder() .resources(hasResources, false) diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java index 29c2fef4261..5cb7e954785 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java @@ -22,9 +22,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.rest.unirest.config.IConnectionConfig; +import com.fortify.cli.common.util.DateTimePeriodHelper; +import com.fortify.cli.common.util.DateTimePeriodHelper.Period; import com.fortify.cli.util._common.helper.AsyncJobManager; +import kong.unirest.Config; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @Reflectable @@ -49,15 +54,51 @@ public enum Product { @Data @NoArgsConstructor @Reflectable @JsonIgnoreProperties(ignoreUnknown = true) - public static class SscConfig { + public abstract static class ConnectionConfig implements IConnectionConfig { + private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.SECONDS, Period.MINUTES); + + private Boolean insecureModeEnabled = false; + private String socketTimeout; + private String connectTimeout; + + @Override + public int getConnectTimeoutInMillis() { + return StringUtils.isBlank(connectTimeout) + ? Config.DEFAULT_CONNECT_TIMEOUT + : (int)PERIOD_HELPER.parsePeriodToMillis(connectTimeout); + } + + @Override + public int getSocketTimeoutInMillis() { + return StringUtils.isBlank(socketTimeout) + ? getDefaultSocketTimeoutInMillis() + : (int)PERIOD_HELPER.parsePeriodToMillis(socketTimeout); + } + + protected abstract int getDefaultSocketTimeoutInMillis(); + } + + @Data @NoArgsConstructor @Reflectable @EqualsAndHashCode(callSuper = true) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SscConfig extends ConnectionConfig { private String url; private String scSastClientAuthToken; + + @Override + protected int getDefaultSocketTimeoutInMillis() { + return 600000; + } } - @Data @NoArgsConstructor @Reflectable + @Data @NoArgsConstructor @Reflectable @EqualsAndHashCode(callSuper = true) @JsonIgnoreProperties(ignoreUnknown = true) - public static class FoDConfig { + public static class FoDConfig extends ConnectionConfig { private String url; + + @Override + protected int getDefaultSocketTimeoutInMillis() { + return 600000; + } } public void validate(Path configPath) { @@ -117,6 +158,8 @@ private void validateSscConfig() { if ( StringUtils.isBlank(ssc.getUrl()) ) { throw new FcliSimpleException("HTTP MCP config ssc.url must be specified"); } + ssc.getConnectTimeoutInMillis(); + ssc.getSocketTimeoutInMillis(); } private void validateFoDConfig() { @@ -126,5 +169,7 @@ private void validateFoDConfig() { if ( StringUtils.isBlank(fod.getUrl()) ) { throw new FcliSimpleException("HTTP MCP config fod.url must be specified"); } + fod.getConnectTimeoutInMillis(); + fod.getSocketTimeoutInMillis(); } } \ No newline at end of file diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java index d9c280ad0de..81cadc2ec4c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java @@ -17,6 +17,7 @@ import java.util.concurrent.ConcurrentHashMap; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.util._common.helper.AsyncJobManager; import com.fortify.cli.util._common.helper.CachingJobEventListener; import com.fortify.cli.util._common.helper.IAsyncTask; @@ -73,11 +74,13 @@ public InProgressEntry getOrStartBackground(String jobId, boolean refresh, IAsyn return new InProgressEntry(jobId, cachingListener, jobTokens.get(jobId)); } // Start new background job with the semantic jobId + var transientSessionDescriptors = Map.copyOf(FcliExecutionContextHolder.current().getTransientSessionDescriptors()); delegate.startBackground(AsyncJobManager.TaskDescriptor.builder() .jobId(jobId) .task(task) .listener(cachingListener) .description("mcp:" + jobId) + .executionContextConfigurer(ctx -> transientSessionDescriptors.values().forEach(ctx::setTransientSessionDescriptor)) .build()); var future = delegate.getFuture(jobId); if (future != null) { diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 4d3622006be..d66cc789246 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -107,14 +107,22 @@ fcli.util.mcp-server.start.progress-interval = Interval between internal progres fcli.util.mcp-server.start.async-bg-threads = Number of background threads for running async streaming jobs. Default: 2. fcli.util.mcp-server.start-http.usage.header = (PREVIEW) Start import-only HTTP fcli MCP server for LLM integration fcli.util.mcp-server.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. This command uses an embedded JDK HTTP server and serves MCP requests on /mcp. Although the Java MCP SDK provides built-in HTTP transports in the core module, those transports are servlet-based and still require a servlet container/runtime; fcli uses JDK HttpServer to avoid adding servlet/container dependencies and to reduce native-image integration risk.\ + %n%nAUTH HEADERS (per HTTP request):\ + %n- SSC mode: required X-FCLI-SSC-TOKEN, optional X-FCLI-SC-SAST-CLIENT-AUTH-TOKEN (used if not set in server config).\ + %n- FoD mode, user/PAT auth: X-FCLI-FOD-TENANT, X-FCLI-FOD-USER, X-FCLI-FOD-PAT.\ + %n- FoD mode, client auth: X-FCLI-FOD-CLIENT-ID, X-FCLI-FOD-CLIENT-SECRET.\ + %nExactly one FoD auth mode must be specified per request.\ %n%nCONFIG FILE STRUCTURE (YAML):\ - %nSpecify exactly one of the 'ssc' or 'fod' sections; product is inferred from section presence.\ + %nSpecify exactly one of the 'ssc' or 'fod' sections; product is inferred from section presence. Each product section also supports optional connection settings: insecureModeEnabled, connectTimeout, socketTimeout. If omitted, defaults match the existing session login commands for that product.\ %n%nSSC example:\ %nport: 8080\ %nimports:\ %n - actions/http-ssc.yaml\ %nssc:\ %n url: https://ssc.example.com\ + %n connectTimeout: 30s\ + %n socketTimeout: 10m\ + %n insecureModeEnabled: false\ %n scSastClientAuthToken: ${#env('SSC_SAST_CLIENT_AUTH_TOKEN')}\ %n%nFoD example:\ %nport: 8080\ @@ -122,6 +130,9 @@ fcli.util.mcp-server.start-http.usage.description = Start an HTTP MCP server exp %n - actions/http-fod.yaml\ %nfod:\ %n url: https://api.ams.fortify.com\ + %n connectTimeout: 30s\ + %n socketTimeout: 10m\ + %n insecureModeEnabled: false\ %n%nOptional runtime settings (with defaults): workThreads=10, progressThreads=4, asyncBgThreads=2, jobSafeReturn=25s, progressInterval=5s fcli.util.mcp-server.start-http.config = Path to HTTP MCP YAML config file. From 2184be9fb432b03828c636cc613e2c953565513f Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 8 May 2026 10:30:34 +0200 Subject: [PATCH 07/55] chore: Code improvements --- .../cli/cmd/MCPServerStartHttpCommand.java | 285 +-------------- .../JdkHttpServerMcpStatelessTransport.java | 5 +- ...CPServerHttpSessionDescriptorResolver.java | 331 ++++++++++++++++++ ...rverHttpSessionDescriptorResolverTest.java | 87 +++++ 4 files changed, 434 insertions(+), 274 deletions(-) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolver.java create mode 100644 fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolverTest.java diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java index 0e3cd6ac050..58269b52437 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java @@ -15,15 +15,9 @@ import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.function.Supplier; -import org.apache.commons.lang3.StringUtils; - import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; @@ -31,28 +25,13 @@ import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.mcp.MCPExclude; -import com.fortify.cli.common.rest.unirest.config.UrlConfig; -import com.fortify.cli.common.session.helper.ISessionDescriptor; import com.fortify.cli.common.util.DateTimePeriodHelper; import com.fortify.cli.common.util.DateTimePeriodHelper.Period; import com.fortify.cli.common.util.FcliBuildProperties; -import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; -import com.fortify.cli.fod._common.session.helper.FoDSessionDescriptor; -import com.fortify.cli.fod._common.session.helper.oauth.FoDOAuthHelper; -import com.fortify.cli.fod._common.session.helper.oauth.FoDTokenCreateResponse; -import com.fortify.cli.fod._common.session.helper.oauth.IFoDClientCredentials; -import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials; -import com.fortify.cli.ssc._common.session.cli.mixin.SSCAndScanCentralSessionLoginOptions.SSCAndScanCentralUrlConfigOptions.SSCComponentDisable; -import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralCredentialsConfig; -import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralUrlConfig; -import com.fortify.cli.ssc._common.session.helper.ISSCUserCredentialsConfig; -import com.fortify.cli.ssc._common.session.helper.SSCAndScanCentralSessionDescriptor; -import com.fortify.cli.ssc.access_control.helper.SSCTokenGetOrCreateResponse.SSCTokenData; import com.fortify.cli.util._common.helper.AsyncJobManager; import com.fortify.cli.util.mcp_server.helper.http.JdkHttpServerMcpStatelessTransport; -import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfig; -import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfig.Product; import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfigLoader; +import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpSessionDescriptorResolver; import com.fortify.cli.util.mcp_server.helper.mcp.MCPImportedActionMcpSpecsFactory; import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; @@ -70,14 +49,6 @@ @Slf4j public class MCPServerStartHttpCommand extends AbstractRunnableCommand { private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); - private static final String HEADER_SSC_TOKEN = "X-FCLI-SSC-TOKEN"; - private static final String HEADER_SC_SAST_CLIENT_AUTH_TOKEN = "X-FCLI-SC-SAST-CLIENT-AUTH-TOKEN"; - private static final String HEADER_FOD_TENANT = "X-FCLI-FOD-TENANT"; - private static final String HEADER_FOD_USER = "X-FCLI-FOD-USER"; - private static final String HEADER_FOD_PAT = "X-FCLI-FOD-PAT"; - private static final String HEADER_FOD_CLIENT_ID = "X-FCLI-FOD-CLIENT-ID"; - private static final String HEADER_FOD_CLIENT_SECRET = "X-FCLI-FOD-CLIENT-SECRET"; - private static final String[] DEFAULT_FOD_SCOPES = new String[] {"api-tenant"}; @Option(names = {"--config"}, required = true) private Path configPath; @@ -106,28 +77,28 @@ public Integer call() throws Exception { var sharedFunctionContext = new FcliExecutionContext(); var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, sharedFunctionContext); + var sessionDescriptorResolver = new MCPServerHttpSessionDescriptorResolver(config); var toolSpecs = new ArrayList(); var resourceTemplateSpecs = new ArrayList(); - var sessionDescriptorCache = new ConcurrentHashMap(); for ( var importPath : config.getResolvedImportPaths() ) { var importedSpecs = importSpecsFactory.create(importPath); importedSpecs.tools().forEach(tool -> toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() .tool(tool.tool()) - .callHandler((ctx, request) -> withRequestExecutionContext(ctx, config.getProduct(), sessionDescriptorCache, - () -> tool.callHandler().apply(ctx, request), config)) + .callHandler((ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, + () -> tool.callHandler().apply(ctx, request))) .build())); importedSpecs.resourceTemplates().forEach(resourceTemplate -> resourceTemplateSpecs.add( new McpStatelessServerFeatures.SyncResourceTemplateSpecification( resourceTemplate.resourceTemplate(), - (ctx, request) -> withRequestExecutionContext(ctx, config.getProduct(), sessionDescriptorCache, - () -> resourceTemplate.readHandler().apply(ctx, request), config) + (ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, + () -> resourceTemplate.readHandler().apply(ctx, request)) ))); } var jobToolSpec = jobManager.getJobToolSpecification(); toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() .tool(jobToolSpec.tool()) - .callHandler((ctx, request) -> withRequestExecutionContext(ctx, config.getProduct(), sessionDescriptorCache, - () -> jobToolSpec.callHandler().apply(null, request), config)) + .callHandler((ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, + () -> jobToolSpec.callHandler().apply(null, request))) .build()); if ( toolSpecs.size() == 1 ) { @@ -163,251 +134,19 @@ public Integer call() throws Exception { return 0; } - private T withRequestExecutionContext(McpTransportContext transportContext, Product product, - Map sessionDescriptorCache, Supplier supplier, - MCPServerHttpConfig config) + private T withRequestExecutionContext(McpTransportContext transportContext, + MCPServerHttpSessionDescriptorResolver sessionDescriptorResolver, + Supplier supplier) { var executionContext = FcliExecutionContextHolder.pushNew(); try { - executionContext.setTransientSessionDescriptor(getOrCreateSessionDescriptor(transportContext, product, sessionDescriptorCache, config)); + executionContext.setTransientSessionDescriptor(sessionDescriptorResolver.getOrCreateSessionDescriptor(transportContext)); return supplier.get(); } finally { FcliExecutionContextHolder.pop(); } } - private ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext transportContext, Product product, - Map sessionDescriptorCache, - MCPServerHttpConfig config) - { - var cacheKey = createAuthCacheKey(transportContext, product); - return sessionDescriptorCache.computeIfAbsent(cacheKey, k -> createSessionDescriptor(product, transportContext, config)); - } - - private String createAuthCacheKey(McpTransportContext transportContext, Product product) { - return switch (product) { - case ssc -> String.format("ssc|%s|%s", - getRequiredHeader(transportContext, HEADER_SSC_TOKEN), - StringUtils.defaultString(getOptionalHeader(transportContext, HEADER_SC_SAST_CLIENT_AUTH_TOKEN))); - case fod -> createFoDAuthCacheKey(transportContext); - }; - } - - private String createFoDAuthCacheKey(McpTransportContext transportContext) { - var clientId = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_ID); - var clientSecret = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_SECRET); - var tenant = getOptionalHeader(transportContext, HEADER_FOD_TENANT); - var user = getOptionalHeader(transportContext, HEADER_FOD_USER); - var pat = getOptionalHeader(transportContext, HEADER_FOD_PAT); - if ( StringUtils.isNotBlank(clientId) || StringUtils.isNotBlank(clientSecret) ) { - if ( StringUtils.isAnyBlank(clientId, clientSecret) ) { - throw new FcliSimpleException("FoD client authentication requires both %s and %s", HEADER_FOD_CLIENT_ID, HEADER_FOD_CLIENT_SECRET); - } - if ( StringUtils.isNotBlank(tenant) || StringUtils.isNotBlank(user) || StringUtils.isNotBlank(pat) ) { - throw new FcliSimpleException("Specify either FoD client headers (%s, %s) or FoD user headers (%s, %s, %s)", - HEADER_FOD_CLIENT_ID, HEADER_FOD_CLIENT_SECRET, - HEADER_FOD_TENANT, HEADER_FOD_USER, HEADER_FOD_PAT); - } - return "fod-client|" + clientId + "|" + clientSecret; - } - if ( StringUtils.isAnyBlank(tenant, user, pat) ) { - throw new FcliSimpleException("FoD user authentication requires headers %s, %s, and %s", - HEADER_FOD_TENANT, HEADER_FOD_USER, HEADER_FOD_PAT); - } - return "fod-user|" + tenant + "|" + user + "|" + pat; - } - - private ISessionDescriptor createSessionDescriptor(Product product, McpTransportContext transportContext, - MCPServerHttpConfig config) - { - return switch (product) { - case ssc -> createSscSessionDescriptor(transportContext, config); - case fod -> createFoDSessionDescriptor(transportContext, config); - }; - } - - private ISessionDescriptor createSscSessionDescriptor(McpTransportContext transportContext, MCPServerHttpConfig config) - { - var token = getRequiredHeader(transportContext, HEADER_SSC_TOKEN); - var tokenData = new SSCTokenData(); - tokenData.setToken(token.toCharArray()); - var sscConfig = config.getSsc(); - var scSastClientAuthToken = StringUtils.firstNonBlank( - sscConfig.getScSastClientAuthToken(), - getOptionalHeader(transportContext, HEADER_SC_SAST_CLIENT_AUTH_TOKEN) - ); - return SSCAndScanCentralSessionDescriptor.create( - new HttpMcpSscUrlConfig(sscConfig), - new HttpMcpSscCredentialsConfig(tokenData.getToken(), - StringUtils.isBlank(scSastClientAuthToken) ? null : scSastClientAuthToken.toCharArray()) - ); - } - - private ISessionDescriptor createFoDSessionDescriptor(McpTransportContext transportContext, MCPServerHttpConfig config) - { - var fodConfig = config.getFod(); - var urlConfig = UrlConfig.builderFromConnectionConfig(fodConfig) - .url(FoDProductHelper.INSTANCE.getApiUrl(fodConfig.getUrl())) - .build(); - var fodTokenResponse = createFoDTokenResponse(transportContext, urlConfig); - return new FoDSessionDescriptor(urlConfig, fodTokenResponse); - } - - private FoDTokenCreateResponse createFoDTokenResponse(McpTransportContext transportContext, UrlConfig urlConfig) { - var clientId = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_ID); - var clientSecret = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_SECRET); - if ( StringUtils.isNotBlank(clientId) || StringUtils.isNotBlank(clientSecret) ) { - return FoDOAuthHelper.createToken(urlConfig, - new HttpMcpFoDClientCredentials( - getRequiredHeader(transportContext, HEADER_FOD_CLIENT_ID), - getRequiredHeader(transportContext, HEADER_FOD_CLIENT_SECRET) - ), - DEFAULT_FOD_SCOPES - ); - } - return FoDOAuthHelper.createToken(urlConfig, - new HttpMcpFoDUserCredentials( - getRequiredHeader(transportContext, HEADER_FOD_TENANT), - getRequiredHeader(transportContext, HEADER_FOD_USER), - getRequiredHeader(transportContext, HEADER_FOD_PAT).toCharArray() - ), - DEFAULT_FOD_SCOPES - ); - } - - @SuppressWarnings("unchecked") - private String getOptionalHeader(McpTransportContext transportContext, String headerName) { - var headers = (Map>)transportContext.get("headers"); - if ( headers == null || headers.isEmpty() ) { - return null; - } - return headers.entrySet().stream() - .filter(e -> headerName.equalsIgnoreCase(e.getKey())) - .map(Map.Entry::getValue) - .filter(v -> v != null && !v.isEmpty()) - .map(v -> v.get(0)) - .map(StringUtils::trimToNull) - .findFirst().orElse(null); - } - - private String getRequiredHeader(McpTransportContext transportContext, String headerName) { - var value = getOptionalHeader(transportContext, headerName); - if ( StringUtils.isBlank(value) ) { - throw new FcliSimpleException("Missing required HTTP header: %s", headerName); - } - return value; - } - - private static final class HttpMcpFoDClientCredentials implements IFoDClientCredentials { - private final String clientId; - private final String clientSecret; - - private HttpMcpFoDClientCredentials(String clientId, String clientSecret) { - this.clientId = clientId; - this.clientSecret = clientSecret; - } - - @Override - public String getClientId() { - return clientId; - } - - @Override - public String getClientSecret() { - return clientSecret; - } - } - - private static final class HttpMcpFoDUserCredentials implements IFoDUserCredentials { - private final String tenant; - private final String user; - private final char[] password; - - private HttpMcpFoDUserCredentials(String tenant, String user, char[] password) { - this.tenant = tenant; - this.user = user; - this.password = password; - } - - @Override - public String getUser() { - return user; - } - - @Override - public char[] getPassword() { - return password; - } - - @Override - public String getTenant() { - return tenant; - } - } - - private static final class HttpMcpSscUrlConfig implements ISSCAndScanCentralUrlConfig { - private final MCPServerHttpConfig.SscConfig config; - - private HttpMcpSscUrlConfig(MCPServerHttpConfig.SscConfig config) { - this.config = config; - } - - @Override - public String getSscUrl() { - return config.getUrl(); - } - - @Override - public String getScSastControllerUrl() { - return null; - } - - @Override - public Set getDisabledComponents() { - return new HashSet<>(); - } - - @Override - public int getConnectTimeoutInMillis() { - return config.getConnectTimeoutInMillis(); - } - - @Override - public int getSocketTimeoutInMillis() { - return config.getSocketTimeoutInMillis(); - } - - @Override - public Boolean getInsecureModeEnabled() { - return config.getInsecureModeEnabled(); - } - } - - private static final class HttpMcpSscCredentialsConfig implements ISSCAndScanCentralCredentialsConfig { - private final char[] sscToken; - private final char[] scSastClientAuthToken; - - private HttpMcpSscCredentialsConfig(char[] sscToken, char[] scSastClientAuthToken) { - this.sscToken = sscToken; - this.scSastClientAuthToken = scSastClientAuthToken; - } - - @Override - public char[] getSscToken() { - return sscToken; - } - - @Override - public ISSCUserCredentialsConfig getSscUserCredentialsConfig() { - return null; - } - - @Override - public char[] getScSastClientAuthToken() { - return scSastClientAuthToken; - } - } - private static ServerCapabilities getServerCapabilities(boolean hasResources) { return ServerCapabilities.builder() .resources(hasResources, false) diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java index 57b383ad609..d1a27354b1e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -28,11 +28,13 @@ import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpStatelessServerTransport; +import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; /** * JDK {@link HttpServer}-based MCP stateless transport implementation. */ +@Slf4j public class JdkHttpServerMcpStatelessTransport implements McpStatelessServerTransport { private static final String APPLICATION_JSON = "application/json"; private static final String TEXT_EVENT_STREAM = "text/event-stream"; @@ -121,8 +123,9 @@ private void handleExchange(HttpExchange exchange) throws IOException { .message("Invalid message format") .build()); } catch (Exception e) { + log.error("Unexpected error while handling MCP HTTP request", e); sendMcpError(exchange, 500, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) - .message("Unexpected error: " + e.getMessage()) + .message("Unexpected server error") .build()); } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolver.java new file mode 100644 index 00000000000..8756a864c64 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -0,0 +1,331 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.helper.http; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.rest.unirest.config.UrlConfig; +import com.fortify.cli.common.session.helper.ISessionDescriptor; +import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; +import com.fortify.cli.fod._common.session.helper.FoDSessionDescriptor; +import com.fortify.cli.fod._common.session.helper.oauth.FoDOAuthHelper; +import com.fortify.cli.fod._common.session.helper.oauth.FoDTokenCreateResponse; +import com.fortify.cli.fod._common.session.helper.oauth.IFoDClientCredentials; +import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials; +import com.fortify.cli.ssc._common.session.cli.mixin.SSCAndScanCentralSessionLoginOptions.SSCAndScanCentralUrlConfigOptions.SSCComponentDisable; +import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralCredentialsConfig; +import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralUrlConfig; +import com.fortify.cli.ssc._common.session.helper.ISSCUserCredentialsConfig; +import com.fortify.cli.ssc._common.session.helper.SSCAndScanCentralSessionDescriptor; +import com.fortify.cli.ssc.access_control.helper.SSCTokenGetOrCreateResponse.SSCTokenData; + +import io.modelcontextprotocol.common.McpTransportContext; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public final class MCPServerHttpSessionDescriptorResolver { + public static final String HEADER_SSC_TOKEN = "X-FCLI-SSC-TOKEN"; + public static final String HEADER_SC_SAST_CLIENT_AUTH_TOKEN = "X-FCLI-SC-SAST-CLIENT-AUTH-TOKEN"; + public static final String HEADER_FOD_TENANT = "X-FCLI-FOD-TENANT"; + public static final String HEADER_FOD_USER = "X-FCLI-FOD-USER"; + public static final String HEADER_FOD_PAT = "X-FCLI-FOD-PAT"; + public static final String HEADER_FOD_CLIENT_ID = "X-FCLI-FOD-CLIENT-ID"; + public static final String HEADER_FOD_CLIENT_SECRET = "X-FCLI-FOD-CLIENT-SECRET"; + + private static final int MAX_SESSION_DESCRIPTOR_CACHE_SIZE = 256; + private static final String[] DEFAULT_FOD_SCOPES = new String[] {"api-tenant"}; + + private final MCPServerHttpConfig config; + private final Map sessionDescriptorCache = new LinkedHashMap<>(16, 0.75f, true) { + private static final long serialVersionUID = 1L; + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_SESSION_DESCRIPTOR_CACHE_SIZE; + } + }; + + public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext transportContext) { + var cacheKey = createAuthCacheKey(transportContext); + synchronized (sessionDescriptorCache) { + return sessionDescriptorCache.computeIfAbsent(cacheKey, ignored -> createSessionDescriptor(transportContext)); + } + } + + String createAuthCacheKey(McpTransportContext transportContext) { + return switch (config.getProduct()) { + case ssc -> createSscAuthCacheKey(transportContext); + case fod -> createFoDAuthCacheKey(transportContext); + }; + } + + private String createSscAuthCacheKey(McpTransportContext transportContext) { + return createHashedCacheKey( + "ssc", + getRequiredHeader(transportContext, HEADER_SSC_TOKEN), + StringUtils.defaultString(getOptionalHeader(transportContext, HEADER_SC_SAST_CLIENT_AUTH_TOKEN)) + ); + } + + private String createFoDAuthCacheKey(McpTransportContext transportContext) { + var clientId = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_ID); + var clientSecret = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_SECRET); + var tenant = getOptionalHeader(transportContext, HEADER_FOD_TENANT); + var user = getOptionalHeader(transportContext, HEADER_FOD_USER); + var pat = getOptionalHeader(transportContext, HEADER_FOD_PAT); + if ( StringUtils.isNotBlank(clientId) || StringUtils.isNotBlank(clientSecret) ) { + validateFoDClientAuthHeaders(clientId, clientSecret, tenant, user, pat); + return createHashedCacheKey("fod-client", clientId, clientSecret); + } + validateFoDUserAuthHeaders(tenant, user, pat); + return createHashedCacheKey("fod-user", tenant, user, pat); + } + + private void validateFoDClientAuthHeaders(String clientId, String clientSecret, String tenant, String user, String pat) { + if ( StringUtils.isAnyBlank(clientId, clientSecret) ) { + throw new FcliSimpleException("FoD client authentication requires both %s and %s", + HEADER_FOD_CLIENT_ID, HEADER_FOD_CLIENT_SECRET); + } + if ( StringUtils.isNotBlank(tenant) || StringUtils.isNotBlank(user) || StringUtils.isNotBlank(pat) ) { + throw new FcliSimpleException("Specify either FoD client headers (%s, %s) or FoD user headers (%s, %s, %s)", + HEADER_FOD_CLIENT_ID, HEADER_FOD_CLIENT_SECRET, + HEADER_FOD_TENANT, HEADER_FOD_USER, HEADER_FOD_PAT); + } + } + + private void validateFoDUserAuthHeaders(String tenant, String user, String pat) { + if ( StringUtils.isAnyBlank(tenant, user, pat) ) { + throw new FcliSimpleException("FoD user authentication requires headers %s, %s, and %s", + HEADER_FOD_TENANT, HEADER_FOD_USER, HEADER_FOD_PAT); + } + } + + private String createHashedCacheKey(String prefix, String... values) { + var digest = getDigest(); + for ( var value : values ) { + if ( value != null ) { + digest.update(value.getBytes(StandardCharsets.UTF_8)); + } + digest.update((byte)0); + } + return prefix + "|" + HexFormat.of().formatHex(digest.digest()); + } + + private MessageDigest getDigest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch ( NoSuchAlgorithmException e ) { + throw new IllegalStateException("SHA-256 digest algorithm is not available", e); + } + } + + private ISessionDescriptor createSessionDescriptor(McpTransportContext transportContext) { + return switch (config.getProduct()) { + case ssc -> createSscSessionDescriptor(transportContext); + case fod -> createFoDSessionDescriptor(transportContext); + }; + } + + private ISessionDescriptor createSscSessionDescriptor(McpTransportContext transportContext) { + var tokenData = new SSCTokenData(); + tokenData.setToken(getRequiredHeader(transportContext, HEADER_SSC_TOKEN).toCharArray()); + var sscConfig = config.getSsc(); + var scSastClientAuthToken = StringUtils.firstNonBlank( + sscConfig.getScSastClientAuthToken(), + getOptionalHeader(transportContext, HEADER_SC_SAST_CLIENT_AUTH_TOKEN) + ); + return SSCAndScanCentralSessionDescriptor.create( + new HttpMcpSscUrlConfig(sscConfig), + new HttpMcpSscCredentialsConfig( + tokenData.getToken(), + StringUtils.isBlank(scSastClientAuthToken) ? null : scSastClientAuthToken.toCharArray() + ) + ); + } + + private ISessionDescriptor createFoDSessionDescriptor(McpTransportContext transportContext) { + var fodConfig = config.getFod(); + var urlConfig = UrlConfig.builderFromConnectionConfig(fodConfig) + .url(FoDProductHelper.INSTANCE.getApiUrl(fodConfig.getUrl())) + .build(); + return new FoDSessionDescriptor(urlConfig, createFoDTokenResponse(transportContext, urlConfig)); + } + + private FoDTokenCreateResponse createFoDTokenResponse(McpTransportContext transportContext, UrlConfig urlConfig) { + var clientId = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_ID); + var clientSecret = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_SECRET); + if ( StringUtils.isNotBlank(clientId) || StringUtils.isNotBlank(clientSecret) ) { + return FoDOAuthHelper.createToken( + urlConfig, + new HttpMcpFoDClientCredentials( + getRequiredHeader(transportContext, HEADER_FOD_CLIENT_ID), + getRequiredHeader(transportContext, HEADER_FOD_CLIENT_SECRET) + ), + DEFAULT_FOD_SCOPES + ); + } + return FoDOAuthHelper.createToken( + urlConfig, + new HttpMcpFoDUserCredentials( + getRequiredHeader(transportContext, HEADER_FOD_TENANT), + getRequiredHeader(transportContext, HEADER_FOD_USER), + getRequiredHeader(transportContext, HEADER_FOD_PAT).toCharArray() + ), + DEFAULT_FOD_SCOPES + ); + } + + @SuppressWarnings("unchecked") + private String getOptionalHeader(McpTransportContext transportContext, String headerName) { + var headers = (Map>)transportContext.get("headers"); + if ( headers == null || headers.isEmpty() ) { + return null; + } + return headers.entrySet().stream() + .filter(entry -> headerName.equalsIgnoreCase(entry.getKey())) + .map(Map.Entry::getValue) + .filter(values -> values != null && !values.isEmpty()) + .map(values -> values.get(0)) + .map(StringUtils::trimToNull) + .findFirst().orElse(null); + } + + private String getRequiredHeader(McpTransportContext transportContext, String headerName) { + var value = getOptionalHeader(transportContext, headerName); + if ( StringUtils.isBlank(value) ) { + throw new FcliSimpleException("Missing required HTTP header: %s", headerName); + } + return value; + } + + private static final class HttpMcpFoDClientCredentials implements IFoDClientCredentials { + private final String clientId; + private final String clientSecret; + + private HttpMcpFoDClientCredentials(String clientId, String clientSecret) { + this.clientId = clientId; + this.clientSecret = clientSecret; + } + + @Override + public String getClientId() { + return clientId; + } + + @Override + public String getClientSecret() { + return clientSecret; + } + } + + private static final class HttpMcpFoDUserCredentials implements IFoDUserCredentials { + private final String tenant; + private final String user; + private final char[] password; + + private HttpMcpFoDUserCredentials(String tenant, String user, char[] password) { + this.tenant = tenant; + this.user = user; + this.password = password; + } + + @Override + public String getUser() { + return user; + } + + @Override + public char[] getPassword() { + return password; + } + + @Override + public String getTenant() { + return tenant; + } + } + + private static final class HttpMcpSscUrlConfig implements ISSCAndScanCentralUrlConfig { + private final MCPServerHttpConfig.SscConfig config; + + private HttpMcpSscUrlConfig(MCPServerHttpConfig.SscConfig config) { + this.config = config; + } + + @Override + public String getSscUrl() { + return config.getUrl(); + } + + @Override + public String getScSastControllerUrl() { + return null; + } + + @Override + public Set getDisabledComponents() { + return new HashSet<>(); + } + + @Override + public int getConnectTimeoutInMillis() { + return config.getConnectTimeoutInMillis(); + } + + @Override + public int getSocketTimeoutInMillis() { + return config.getSocketTimeoutInMillis(); + } + + @Override + public Boolean getInsecureModeEnabled() { + return config.getInsecureModeEnabled(); + } + } + + private static final class HttpMcpSscCredentialsConfig implements ISSCAndScanCentralCredentialsConfig { + private final char[] sscToken; + private final char[] scSastClientAuthToken; + + private HttpMcpSscCredentialsConfig(char[] sscToken, char[] scSastClientAuthToken) { + this.sscToken = sscToken; + this.scSastClientAuthToken = scSastClientAuthToken; + } + + @Override + public char[] getSscToken() { + return sscToken; + } + + @Override + public ISSCUserCredentialsConfig getSscUserCredentialsConfig() { + return null; + } + + @Override + public char[] getScSastClientAuthToken() { + return scSastClientAuthToken; + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolverTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolverTest.java new file mode 100644 index 00000000000..f4a974fe3fa --- /dev/null +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolverTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.helper.http; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.exception.FcliSimpleException; + +import io.modelcontextprotocol.common.McpTransportContext; + +class MCPServerHttpSessionDescriptorResolverTest { + @Test + void createAuthCacheKeyHashesSscCredentials() { + var config = new MCPServerHttpConfig(); + var sscConfig = new MCPServerHttpConfig.SscConfig(); + sscConfig.setUrl("https://ssc.example.com"); + config.setSsc(sscConfig); + var resolver = new MCPServerHttpSessionDescriptorResolver(config); + + var cacheKey = resolver.createAuthCacheKey(transportContext(Map.of( + MCPServerHttpSessionDescriptorResolver.HEADER_SSC_TOKEN, List.of("ssc-token"), + MCPServerHttpSessionDescriptorResolver.HEADER_SC_SAST_CLIENT_AUTH_TOKEN, List.of("sast-token") + ))); + + assertTrue(cacheKey.startsWith("ssc|")); + assertFalse(cacheKey.contains("ssc-token")); + assertFalse(cacheKey.contains("sast-token")); + } + + @Test + void createAuthCacheKeyHashesFoDClientCredentials() { + var config = new MCPServerHttpConfig(); + var fodConfig = new MCPServerHttpConfig.FoDConfig(); + fodConfig.setUrl("https://api.ams.fortify.com"); + config.setFod(fodConfig); + var resolver = new MCPServerHttpSessionDescriptorResolver(config); + + var cacheKey = resolver.createAuthCacheKey(transportContext(Map.of( + MCPServerHttpSessionDescriptorResolver.HEADER_FOD_CLIENT_ID, List.of("client-id"), + MCPServerHttpSessionDescriptorResolver.HEADER_FOD_CLIENT_SECRET, List.of("client-secret") + ))); + + assertTrue(cacheKey.startsWith("fod-client|")); + assertFalse(cacheKey.contains("client-id")); + assertFalse(cacheKey.contains("client-secret")); + } + + @Test + void createAuthCacheKeyRejectsMixedFoDAuthModes() { + var config = new MCPServerHttpConfig(); + var fodConfig = new MCPServerHttpConfig.FoDConfig(); + fodConfig.setUrl("https://api.ams.fortify.com"); + config.setFod(fodConfig); + var resolver = new MCPServerHttpSessionDescriptorResolver(config); + + var exception = assertThrows(FcliSimpleException.class, () -> resolver.createAuthCacheKey(transportContext(Map.of( + MCPServerHttpSessionDescriptorResolver.HEADER_FOD_CLIENT_ID, List.of("client-id"), + MCPServerHttpSessionDescriptorResolver.HEADER_FOD_CLIENT_SECRET, List.of("client-secret"), + MCPServerHttpSessionDescriptorResolver.HEADER_FOD_TENANT, List.of("tenant"), + MCPServerHttpSessionDescriptorResolver.HEADER_FOD_USER, List.of("user"), + MCPServerHttpSessionDescriptorResolver.HEADER_FOD_PAT, List.of("pat") + )))); + + assertTrue(exception.getMessage().contains("Specify either FoD client headers")); + } + + private McpTransportContext transportContext(Map> headers) { + return McpTransportContext.create(Map.of("headers", headers)); + } +} \ No newline at end of file From c380c99b0f0d780f8c15e30fcec5023172f74af4 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 8 May 2026 13:47:09 +0200 Subject: [PATCH 08/55] chore: Move commands, improve implementation --- fcli-core/fcli-agent/build.gradle.kts | 8 + .../agent/_main/cli/cmd/AgentCommands.java | 31 +++ .../agent/mcp/cli/cmd/AgentMCPCommands.java | 27 ++ .../cmd/AgentMCPCreateHttpConfigCommand.java | 80 ++++++ .../cli/cmd/AgentMCPStartHttpCommand.java} | 21 +- .../cli/cmd/AgentMCPStartStdioCommand.java} | 37 ++- .../MCPImportedActionMcpSpecsFactory.java | 10 +- .../cli/agent/mcp/helper}/MCPJobManager.java | 6 +- .../helper}/MCPReflectConfigGenerator.java | 2 +- .../arg/AbstractMCPToolArgHandlerFcli.java | 2 +- .../mcp/helper}/arg/IMCPToolArgHandler.java | 2 +- .../arg/MCPToolArgHandlerActionOption.java | 2 +- .../arg/MCPToolArgHandlerFcliOption.java | 2 +- .../arg/MCPToolArgHandlerFcliParam.java | 2 +- .../helper}/arg/MCPToolArgHandlerPaging.java | 4 +- .../helper}/arg/MCPToolArgHandlerQuery.java | 2 +- .../mcp/helper}/arg/MCPToolArgHandlers.java | 2 +- .../JdkHttpServerMcpStatelessTransport.java | 26 +- .../mcp}/helper/http/MCPServerHttpConfig.java | 4 +- .../http/MCPServerHttpConfigLoader.java | 2 +- ...CPServerHttpSessionDescriptorResolver.java | 257 +++++++++++++++--- .../runner/AbstractMCPToolFcliRunner.java | 6 +- .../mcp/helper}/runner/IMCPToolRunner.java | 2 +- .../runner/MCPResourceFcliRunnerFunction.java | 2 +- .../runner/MCPToolAsyncJobManager.java | 21 +- .../runner/MCPToolFcliPagedHelper.java | 55 +++- .../runner/MCPToolFcliRunnerAction.java | 6 +- .../runner/MCPToolFcliRunnerFunction.java | 4 +- .../MCPToolFcliRunnerFunctionStreaming.java | 20 +- .../runner/MCPToolFcliRunnerHelper.java | 4 +- .../runner/MCPToolFcliRunnerPlainText.java | 6 +- .../runner/MCPToolFcliRunnerRecords.java | 6 +- .../runner/MCPToolFcliRunnerRecordsPaged.java | 8 +- .../mcp/helper}/runner/MCPToolResult.java | 8 +- .../cli/agent/i18n/AgentMessages.properties | 25 ++ .../agent/mcp/config/mcp-http-config-fod.yaml | 8 + .../agent/mcp/config/mcp-http-config-ssc.yaml | 9 + ...rverHttpSessionDescriptorResolverTest.java | 58 +++- .../runner/MCPToolFcliPagedHelperTest.java | 63 +++++ ...CPToolFcliRunnerFunctionStreamingTest.java | 44 +++ .../mcp/helper/runner/MCPToolResultTest.java | 37 +++ .../agent/mcp}/unit/MCPJobManagerTest.java | 6 +- .../unit/MCPServerHttpConfigLoaderTest.java | 6 +- .../mcp}/unit/MCPToolArgHandlersTest.java | 4 +- .../unit/MCPToolFcliRunnerRecordsTest.java | 10 +- fcli-core/fcli-app/build.gradle.kts | 4 +- .../app/_main/cli/cmd/FCLIRootCommands.java | 2 + .../common/cli/util/FcliExecutionContext.java | 10 +- .../cli/util/FcliExecutionContextHolder.java | 10 + .../cli/common/cli/util/FcliModules.java | 2 +- .../concurrent/job}/AsyncJobManager.java | 2 +- .../job}/CachingJobEventListener.java | 2 +- .../job}/CollectingJobEventListener.java | 2 +- .../job}/CompositeJobEventListener.java | 2 +- .../common/concurrent/job}/IAsyncTask.java | 2 +- .../concurrent/job}/IJobEventListener.java | 2 +- .../job}/cli/mixin/AsyncJobManagerMixin.java | 4 +- .../job/exec}/FcliExecutionResult.java | 2 +- .../job/exec}/FcliRunnerHelper.java | 2 +- .../job/task}/AsyncTaskActionFunction.java | 3 +- .../job/task}/AsyncTaskFcliCommand.java | 4 +- .../cli/util/FcliExecutionContextTest.java | 17 ++ fcli-core/fcli-util/build.gradle.kts | 6 - .../mcp_server/cli/cmd/MCPServerCommands.java | 3 +- .../cmd/MCPServerStartDeprecatedCommand.java | 54 ++++ .../cli/cmd/RPCServerStartCommand.java | 4 +- .../helper/RPCJobEventListenerFactory.java | 6 +- .../helper/RPCMethodHandlerFcliExecute.java | 6 +- .../helper/RPCMethodHandlerFnCall.java | 6 +- .../helper/RPCMethodHandlerJobCancel.java | 2 +- .../helper/RPCMethodHandlerJobGetPage.java | 2 +- .../helper/RPCMethodHandlerJobGetStatus.java | 4 +- .../helper/RPCMethodHandlerJobList.java | 2 +- .../helper/RPCMethodHandlerJobRemove.java | 4 +- .../helper/RPCMethodHandlerRegistry.java | 4 +- .../helper/RPCPushJobEventListener.java | 2 +- .../util/rpc_server/helper/RPCWaitHelper.java | 2 +- .../cli/util/i18n/UtilMessages.properties | 53 +--- gradle.properties | 1 + 79 files changed, 918 insertions(+), 262 deletions(-) create mode 100644 fcli-core/fcli-agent/build.gradle.kts create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCommands.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCreateHttpConfigCommand.java rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java} (88%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartCommand.java => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java} (94%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/MCPImportedActionMcpSpecsFactory.java (94%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/MCPJobManager.java (99%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/MCPReflectConfigGenerator.java (97%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/arg/AbstractMCPToolArgHandlerFcli.java (99%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/arg/IMCPToolArgHandler.java (95%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/arg/MCPToolArgHandlerActionOption.java (98%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/arg/MCPToolArgHandlerFcliOption.java (97%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/arg/MCPToolArgHandlerFcliParam.java (96%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/arg/MCPToolArgHandlerPaging.java (95%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/arg/MCPToolArgHandlerQuery.java (99%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/arg/MCPToolArgHandlers.java (98%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server => fcli-agent/src/main/java/com/fortify/cli/agent/mcp}/helper/http/JdkHttpServerMcpStatelessTransport.java (86%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server => fcli-agent/src/main/java/com/fortify/cli/agent/mcp}/helper/http/MCPServerHttpConfig.java (98%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server => fcli-agent/src/main/java/com/fortify/cli/agent/mcp}/helper/http/MCPServerHttpConfigLoader.java (98%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server => fcli-agent/src/main/java/com/fortify/cli/agent/mcp}/helper/http/MCPServerHttpSessionDescriptorResolver.java (53%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/AbstractMCPToolFcliRunner.java (89%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/IMCPToolRunner.java (94%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPResourceFcliRunnerFunction.java (98%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolAsyncJobManager.java (87%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolFcliPagedHelper.java (77%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolFcliRunnerAction.java (94%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolFcliRunnerFunction.java (96%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolFcliRunnerFunctionStreaming.java (79%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolFcliRunnerHelper.java (95%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolFcliRunnerPlainText.java (92%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolFcliRunnerRecords.java (93%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolFcliRunnerRecordsPaged.java (89%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp => fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper}/runner/MCPToolResult.java (96%) create mode 100644 fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties create mode 100644 fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml create mode 100644 fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml rename fcli-core/{fcli-util/src/test/java/com/fortify/cli/util/mcp_server => fcli-agent/src/test/java/com/fortify/cli/agent/mcp}/helper/http/MCPServerHttpSessionDescriptorResolverTest.java (55%) create mode 100644 fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelperTest.java create mode 100644 fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java create mode 100644 fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java rename fcli-core/{fcli-util/src/test/java/com/fortify/cli/util/mcp_server => fcli-agent/src/test/java/com/fortify/cli/agent/mcp}/unit/MCPJobManagerTest.java (97%) rename fcli-core/{fcli-util/src/test/java/com/fortify/cli/util/mcp_server => fcli-agent/src/test/java/com/fortify/cli/agent/mcp}/unit/MCPServerHttpConfigLoaderTest.java (93%) rename fcli-core/{fcli-util/src/test/java/com/fortify/cli/util/mcp_server => fcli-agent/src/test/java/com/fortify/cli/agent/mcp}/unit/MCPToolArgHandlersTest.java (98%) rename fcli-core/{fcli-util/src/test/java/com/fortify/cli/util/mcp_server => fcli-agent/src/test/java/com/fortify/cli/agent/mcp}/unit/MCPToolFcliRunnerRecordsTest.java (93%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job}/AsyncJobManager.java (99%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job}/CachingJobEventListener.java (99%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job}/CollectingJobEventListener.java (98%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job}/CompositeJobEventListener.java (97%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job}/IAsyncTask.java (96%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job}/IJobEventListener.java (97%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job}/cli/mixin/AsyncJobManagerMixin.java (92%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec}/FcliExecutionResult.java (98%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec}/FcliRunnerHelper.java (98%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/task}/AsyncTaskActionFunction.java (95%) rename fcli-core/{fcli-util/src/main/java/com/fortify/cli/util/_common/helper => fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/task}/AsyncTaskFcliCommand.java (91%) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java diff --git a/fcli-core/fcli-agent/build.gradle.kts b/fcli-core/fcli-agent/build.gradle.kts new file mode 100644 index 00000000000..8ba28ff4645 --- /dev/null +++ b/fcli-core/fcli-agent/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { id("fcli.module-conventions") } + +dependencies { + val fodRef = project.findProperty("fcliFoDRef") as String + val sscRef = project.findProperty("fcliSSCRef") as String + implementation(project(fodRef)) + implementation(project(sscRef)) +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java new file mode 100644 index 00000000000..2fed80075e4 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent._main.cli.cmd; + +import static com.fortify.cli.common.cli.util.FcliModuleCategories.UTIL; + +import com.fortify.cli.agent.mcp.cli.cmd.AgentMCPCommands; +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.FcliModuleCategory; + +import picocli.CommandLine.Command; + +@FcliModuleCategory(UTIL) +@Command( + name = "agent", + resourceBundle = "com.fortify.cli.agent.i18n.AgentMessages", + subcommands = { + AgentMCPCommands.class + } +) +public class AgentCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCommands.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCommands.java new file mode 100644 index 00000000000..fcc620a2aba --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCommands.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.mcp.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +@Command( + name = "mcp", + subcommands = { + AgentMCPStartStdioCommand.class, + AgentMCPStartHttpCommand.class, + AgentMCPCreateHttpConfigCommand.class + } +) +public class AgentMCPCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCreateHttpConfigCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCreateHttpConfigCommand.java new file mode 100644 index 00000000000..6b1d55b4b02 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCreateHttpConfigCommand.java @@ -0,0 +1,80 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.mcp.cli.cmd; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.mcp.MCPExclude; + +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "create-http-config") +@MCPExclude +public class AgentMCPCreateHttpConfigCommand extends AbstractRunnableCommand { + @Option(names = {"--type", "-t"}, required = true) + private HttpConfigType type; + + @Option(names = {"--config", "-c"}, defaultValue = "mcp-http-config.yaml") + private Path configPath; + + @Option(names = {"--force", "-f"}, defaultValue = "false") + private boolean force; + + @Override + public Integer call() { + var outputPath = configPath.toAbsolutePath().normalize(); + if ( Files.exists(outputPath) && !force ) { + throw new FcliSimpleException("Config file already exists; specify --force to overwrite: " + outputPath); + } + var parent = outputPath.getParent(); + try { + if ( parent != null ) { + Files.createDirectories(parent); + } + Files.writeString(outputPath, loadTemplate(), StandardCharsets.UTF_8, + force ? new StandardOpenOption[] {StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING} + : new StandardOpenOption[] {StandardOpenOption.CREATE_NEW}); + } catch (IOException e) { + throw new FcliSimpleException("Error writing HTTP MCP config file: %s", outputPath); + } + System.out.printf("Created HTTP MCP config file: %s%n", outputPath); + return 0; + } + + private String loadTemplate() { + var templateResource = switch (type) { + case ssc -> "/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml"; + case fod -> "/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml"; + }; + try ( var inputStream = getClass().getResourceAsStream(templateResource) ) { + if ( inputStream == null ) { + throw new FcliSimpleException("Missing HTTP MCP template resource: %s", templateResource); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new FcliSimpleException("Error reading HTTP MCP template resource: %s", templateResource); + } + } + + private enum HttpConfigType { + ssc, + fod + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java similarity index 88% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index 58269b52437..ac83772c04e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.cli.cmd; +package com.fortify.cli.agent.mcp.cli.cmd; import java.nio.file.Path; import java.time.Duration; @@ -20,20 +20,20 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.agent.mcp.helper.MCPImportedActionMcpSpecsFactory; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.http.JdkHttpServerMcpStatelessTransport; +import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfigLoader; +import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpSessionDescriptorResolver; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.mcp.MCPExclude; import com.fortify.cli.common.util.DateTimePeriodHelper; import com.fortify.cli.common.util.DateTimePeriodHelper.Period; import com.fortify.cli.common.util.FcliBuildProperties; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util.mcp_server.helper.http.JdkHttpServerMcpStatelessTransport; -import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfigLoader; -import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpSessionDescriptorResolver; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPImportedActionMcpSpecsFactory; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; @@ -47,10 +47,10 @@ @Command(name = "start-http") @MCPExclude @Slf4j -public class MCPServerStartHttpCommand extends AbstractRunnableCommand { +public class AgentMCPStartHttpCommand extends AbstractRunnableCommand { private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); - @Option(names = {"--config"}, required = true) + @Option(names = {"--config", "-c"}, required = true) private Path configPath; @Override @@ -140,6 +140,9 @@ private T withRequestExecutionContext(McpTransportContext transportContext, { var executionContext = FcliExecutionContextHolder.pushNew(); try { + // HTTP MCP is stateless, so per-request auth/session data must be attached here + // for downstream session resolution and paged/background job isolation. + executionContext.setMcpRequestAuthScopeKey(sessionDescriptorResolver.getAuthScopeKey(transportContext)); executionContext.setTransientSessionDescriptor(sessionDescriptorResolver.getOrCreateSessionDescriptor(transportContext)); return supplier.get(); } finally { diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java similarity index 94% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartCommand.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java index acd19e8f95c..f41c543fb1c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.cli.cmd; +package com.fortify.cli.agent.mcp.cli.cmd; import java.io.FilterInputStream; import java.io.IOException; @@ -27,6 +27,19 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.IMCPToolArgHandler; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerActionOption; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.agent.mcp.helper.runner.IMCPToolRunner; +import com.fortify.cli.agent.mcp.helper.runner.MCPResourceFcliRunnerFunction; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerAction; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerFunction; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerFunctionStreaming; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerPlainText; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerRecords; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerRecordsPaged; import com.fortify.cli.common.action.helper.ActionLoaderHelper; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; @@ -39,30 +52,16 @@ import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.StdioHelper; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.cli.mixin.AsyncJobManagerMixin; import com.fortify.cli.common.exception.FcliBugException; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.mcp.MCPExclude; -import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.util.DateTimePeriodHelper; import com.fortify.cli.common.util.DateTimePeriodHelper.Period; import com.fortify.cli.common.util.DisableTest; import com.fortify.cli.common.util.DisableTest.TestType; import com.fortify.cli.common.util.FcliBuildProperties; -import com.fortify.cli.util._common.cli.mixin.AsyncJobManagerMixin; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.IMCPToolArgHandler; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlerActionOption; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlerPaging; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlers; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.IMCPToolRunner; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPResourceFcliRunnerFunction; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerAction; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerFunction; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerFunctionStreaming; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerPlainText; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerRecords; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerRecordsPaged; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServer; @@ -81,10 +80,10 @@ import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; -@Command(name = OutputHelperMixins.Start.CMD_NAME) +@Command(name = "start-stdio") @MCPExclude // Doesn't make sense to allow mcp-server start command to be called from MCP server @Slf4j -public class MCPServerStartCommand extends AbstractRunnableCommand { +public class AgentMCPStartStdioCommand extends AbstractRunnableCommand { @Option(names={"--module", "-m"}, required = false) private McpModule module; @DisableTest(TestType.MULTI_OPT_PLURAL_NAME) @Option(names={"--import"}, split=",") private List importFiles; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPImportedActionMcpSpecsFactory.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java similarity index 94% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPImportedActionMcpSpecsFactory.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java index 3c4936833f7..95430f9f6d6 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPImportedActionMcpSpecsFactory.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java @@ -10,23 +10,23 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp; +package com.fortify.cli.agent.mcp.helper; import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.agent.mcp.helper.runner.MCPResourceFcliRunnerFunction; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerFunction; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerFunctionStreaming; import com.fortify.cli.common.action.helper.ActionLoaderHelper; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; import com.fortify.cli.common.action.model.ActionFunction; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.cli.util.FcliExecutionContext; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlerPaging; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPResourceFcliRunnerFunction; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerFunction; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerFunctionStreaming; import io.modelcontextprotocol.server.McpStatelessServerFeatures; import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPJobManager.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java similarity index 99% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPJobManager.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java index 0c938ff81f3..0d1308cb639 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPJobManager.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp; +package com.fortify.cli.agent.mcp.helper; import java.time.Instant; import java.util.ArrayList; @@ -31,8 +31,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolAsyncJobManager; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolAsyncJobManager; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPReflectConfigGenerator.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPReflectConfigGenerator.java similarity index 97% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPReflectConfigGenerator.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPReflectConfigGenerator.java index f25d07ed44b..db054aade56 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPReflectConfigGenerator.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPReflectConfigGenerator.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp; +package com.fortify.cli.agent.mcp.helper; import java.io.IOException; import java.nio.file.Files; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/AbstractMCPToolArgHandlerFcli.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java similarity index 99% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/AbstractMCPToolArgHandlerFcli.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java index f45fc434f1b..d1f294fbd03 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/AbstractMCPToolArgHandlerFcli.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.agent.mcp.helper.arg; import java.util.Collection; import java.util.Map; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/IMCPToolArgHandler.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/IMCPToolArgHandler.java similarity index 95% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/IMCPToolArgHandler.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/IMCPToolArgHandler.java index e7db2c6b856..13cbf7781ef 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/IMCPToolArgHandler.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/IMCPToolArgHandler.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.agent.mcp.helper.arg; import java.util.Map; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerActionOption.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerActionOption.java similarity index 98% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerActionOption.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerActionOption.java index 944fc0340c0..260d07ebae3 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerActionOption.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerActionOption.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.agent.mcp.helper.arg; import java.util.Collection; import java.util.Map; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerFcliOption.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliOption.java similarity index 97% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerFcliOption.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliOption.java index 9819a2b800b..65dc494e51b 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerFcliOption.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliOption.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.agent.mcp.helper.arg; import java.util.stream.Collectors; import java.util.stream.Stream; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerFcliParam.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliParam.java similarity index 96% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerFcliParam.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliParam.java index 41442642663..be75b8b76e5 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerFcliParam.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliParam.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.agent.mcp.helper.arg; import java.lang.reflect.Field; import java.util.stream.Collectors; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerPaging.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerPaging.java similarity index 95% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerPaging.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerPaging.java index aeb121280c3..c148a245cc8 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerPaging.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerPaging.java @@ -10,12 +10,12 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.agent.mcp.helper.arg; import java.util.Map; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerRecordsPaged; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerRecordsPaged; import io.modelcontextprotocol.spec.McpSchema.JsonSchema; import lombok.SneakyThrows; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerQuery.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerQuery.java similarity index 99% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerQuery.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerQuery.java index f4802931b61..10f765099e5 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerQuery.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerQuery.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.agent.mcp.helper.arg; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlers.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlers.java similarity index 98% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlers.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlers.java index 2b673c999f1..9ecbe1d3178 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlers.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlers.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.agent.mcp.helper.arg; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java similarity index 86% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java index d1a27354b1e..e1cf3e5f764 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/JdkHttpServerMcpStatelessTransport.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -10,11 +10,12 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.http; +package com.fortify.cli.agent.mcp.helper.http; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -38,6 +39,7 @@ public class JdkHttpServerMcpStatelessTransport implements McpStatelessServerTransport { private static final String APPLICATION_JSON = "application/json"; private static final String TEXT_EVENT_STREAM = "text/event-stream"; + private static final String INITIALIZED_NOTIFICATION_METHOD = "notifications/initialized"; private final HttpServer httpServer; private final String mcpEndpoint; @@ -87,6 +89,8 @@ private void handleExchange(HttpExchange exchange) throws IOException { return; } + log.info("[TEMP DEBUG] Incoming MCP HTTP request headers: {}", formatHeadersForLog(exchange.getRequestHeaders())); + var accept = getFirstHeader(exchange, "Accept"); if ( accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM)) ) { sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) @@ -109,9 +113,13 @@ private void handleExchange(HttpExchange exchange) throws IOException { .block(); sendJson(exchange, 200, response); } else if ( message instanceof McpSchema.JSONRPCNotification notification ) { - mcpHandler.handleNotification(transportContext, notification) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); + if ( INITIALIZED_NOTIFICATION_METHOD.equals(notification.method()) ) { + log.debug("Ignoring MCP initialized notification"); + } else { + mcpHandler.handleNotification(transportContext, notification) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + } sendEmpty(exchange, 202); } else { sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) @@ -168,4 +176,14 @@ private void sendEmpty(HttpExchange exchange, int status) throws IOException { exchange.sendResponseHeaders(status, -1); exchange.close(); } + + private String formatHeadersForLog(Map> headers) { + if ( headers == null || headers.isEmpty() ) { + return "{}"; + } + return headers.entrySet().stream() + .sorted(Comparator.comparing(Map.Entry::getKey, String.CASE_INSENSITIVE_ORDER)) + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining(", ", "{", "}")); + } } \ No newline at end of file diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java similarity index 98% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java index 5cb7e954785..eefae71e511 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.http; +package com.fortify.cli.agent.mcp.helper.http; import java.nio.file.Path; import java.util.ArrayList; @@ -21,11 +21,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.rest.unirest.config.IConnectionConfig; import com.fortify.cli.common.util.DateTimePeriodHelper; import com.fortify.cli.common.util.DateTimePeriodHelper.Period; -import com.fortify.cli.util._common.helper.AsyncJobManager; import kong.unirest.Config; import lombok.Data; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfigLoader.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfigLoader.java similarity index 98% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfigLoader.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfigLoader.java index b365c734da9..aea1be1f976 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpConfigLoader.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfigLoader.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.http; +package com.fortify.cli.agent.mcp.helper.http; import java.io.IOException; import java.nio.file.Files; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java similarity index 53% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolver.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 8756a864c64..0060725ee38 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -10,15 +10,17 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.http; +package com.fortify.cli.agent.mcp.helper.http; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.HashSet; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -45,13 +47,16 @@ @RequiredArgsConstructor public final class MCPServerHttpSessionDescriptorResolver { - public static final String HEADER_SSC_TOKEN = "X-FCLI-SSC-TOKEN"; - public static final String HEADER_SC_SAST_CLIENT_AUTH_TOKEN = "X-FCLI-SC-SAST-CLIENT-AUTH-TOKEN"; - public static final String HEADER_FOD_TENANT = "X-FCLI-FOD-TENANT"; - public static final String HEADER_FOD_USER = "X-FCLI-FOD-USER"; - public static final String HEADER_FOD_PAT = "X-FCLI-FOD-PAT"; - public static final String HEADER_FOD_CLIENT_ID = "X-FCLI-FOD-CLIENT-ID"; - public static final String HEADER_FOD_CLIENT_SECRET = "X-FCLI-FOD-CLIENT-SECRET"; + public static final String HEADER_AUTH_SSC = "X-AUTH-SSC"; + public static final String HEADER_AUTH_FOD = "X-AUTH-FOD"; + + private static final String SSC_TOKEN_KEY = "token"; + private static final String SSC_SC_SAST_CLIENT_AUTH_TOKEN_KEY = "sc-sast-token"; + private static final String FOD_TENANT_KEY = "tenant"; + private static final String FOD_USER_KEY = "user"; + private static final String FOD_PAT_KEY = "pat"; + private static final String FOD_CLIENT_ID_KEY = "client-id"; + private static final String FOD_CLIENT_SECRET_KEY = "client-secret"; private static final int MAX_SESSION_DESCRIPTOR_CACHE_SIZE = 256; private static final String[] DEFAULT_FOD_SCOPES = new String[] {"api-tenant"}; @@ -73,27 +78,32 @@ public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext trans } } + public String getAuthScopeKey(McpTransportContext transportContext) { + return createAuthCacheKey(transportContext); + } + String createAuthCacheKey(McpTransportContext transportContext) { - return switch (config.getProduct()) { - case ssc -> createSscAuthCacheKey(transportContext); - case fod -> createFoDAuthCacheKey(transportContext); + var auth = parseAuthHeader(transportContext); + return switch (auth.product()) { + case ssc -> createSscAuthCacheKey(auth); + case fod -> createFoDAuthCacheKey(auth); }; } - private String createSscAuthCacheKey(McpTransportContext transportContext) { + private String createSscAuthCacheKey(ParsedAuthorization auth) { return createHashedCacheKey( "ssc", - getRequiredHeader(transportContext, HEADER_SSC_TOKEN), - StringUtils.defaultString(getOptionalHeader(transportContext, HEADER_SC_SAST_CLIENT_AUTH_TOKEN)) + auth.sscToken(), + StringUtils.defaultString(auth.scSastClientAuthToken()) ); } - private String createFoDAuthCacheKey(McpTransportContext transportContext) { - var clientId = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_ID); - var clientSecret = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_SECRET); - var tenant = getOptionalHeader(transportContext, HEADER_FOD_TENANT); - var user = getOptionalHeader(transportContext, HEADER_FOD_USER); - var pat = getOptionalHeader(transportContext, HEADER_FOD_PAT); + private String createFoDAuthCacheKey(ParsedAuthorization auth) { + var clientId = auth.fodClientId(); + var clientSecret = auth.fodClientSecret(); + var tenant = auth.fodTenant(); + var user = auth.fodUser(); + var pat = auth.fodPat(); if ( StringUtils.isNotBlank(clientId) || StringUtils.isNotBlank(clientSecret) ) { validateFoDClientAuthHeaders(clientId, clientSecret, tenant, user, pat); return createHashedCacheKey("fod-client", clientId, clientSecret); @@ -104,20 +114,20 @@ private String createFoDAuthCacheKey(McpTransportContext transportContext) { private void validateFoDClientAuthHeaders(String clientId, String clientSecret, String tenant, String user, String pat) { if ( StringUtils.isAnyBlank(clientId, clientSecret) ) { - throw new FcliSimpleException("FoD client authentication requires both %s and %s", - HEADER_FOD_CLIENT_ID, HEADER_FOD_CLIENT_SECRET); + throw new FcliSimpleException("FoD client authentication requires keys %s and %s in %s header", + FOD_CLIENT_ID_KEY, FOD_CLIENT_SECRET_KEY, HEADER_AUTH_FOD); } if ( StringUtils.isNotBlank(tenant) || StringUtils.isNotBlank(user) || StringUtils.isNotBlank(pat) ) { - throw new FcliSimpleException("Specify either FoD client headers (%s, %s) or FoD user headers (%s, %s, %s)", - HEADER_FOD_CLIENT_ID, HEADER_FOD_CLIENT_SECRET, - HEADER_FOD_TENANT, HEADER_FOD_USER, HEADER_FOD_PAT); + throw new FcliSimpleException("Specify either FoD client keys (%s, %s) or FoD user keys (%s, %s, %s) in %s header", + FOD_CLIENT_ID_KEY, FOD_CLIENT_SECRET_KEY, + FOD_TENANT_KEY, FOD_USER_KEY, FOD_PAT_KEY, HEADER_AUTH_FOD); } } private void validateFoDUserAuthHeaders(String tenant, String user, String pat) { if ( StringUtils.isAnyBlank(tenant, user, pat) ) { - throw new FcliSimpleException("FoD user authentication requires headers %s, %s, and %s", - HEADER_FOD_TENANT, HEADER_FOD_USER, HEADER_FOD_PAT); + throw new FcliSimpleException("FoD user authentication requires keys %s, %s, and %s in %s header", + FOD_TENANT_KEY, FOD_USER_KEY, FOD_PAT_KEY, HEADER_AUTH_FOD); } } @@ -141,19 +151,20 @@ private MessageDigest getDigest() { } private ISessionDescriptor createSessionDescriptor(McpTransportContext transportContext) { - return switch (config.getProduct()) { - case ssc -> createSscSessionDescriptor(transportContext); - case fod -> createFoDSessionDescriptor(transportContext); + var auth = parseAuthHeader(transportContext); + return switch (auth.product()) { + case ssc -> createSscSessionDescriptor(auth); + case fod -> createFoDSessionDescriptor(auth); }; } - private ISessionDescriptor createSscSessionDescriptor(McpTransportContext transportContext) { + private ISessionDescriptor createSscSessionDescriptor(ParsedAuthorization auth) { var tokenData = new SSCTokenData(); - tokenData.setToken(getRequiredHeader(transportContext, HEADER_SSC_TOKEN).toCharArray()); + tokenData.setToken(auth.sscToken().toCharArray()); var sscConfig = config.getSsc(); var scSastClientAuthToken = StringUtils.firstNonBlank( sscConfig.getScSastClientAuthToken(), - getOptionalHeader(transportContext, HEADER_SC_SAST_CLIENT_AUTH_TOKEN) + auth.scSastClientAuthToken() ); return SSCAndScanCentralSessionDescriptor.create( new HttpMcpSscUrlConfig(sscConfig), @@ -164,23 +175,23 @@ private ISessionDescriptor createSscSessionDescriptor(McpTransportContext transp ); } - private ISessionDescriptor createFoDSessionDescriptor(McpTransportContext transportContext) { + private ISessionDescriptor createFoDSessionDescriptor(ParsedAuthorization auth) { var fodConfig = config.getFod(); var urlConfig = UrlConfig.builderFromConnectionConfig(fodConfig) .url(FoDProductHelper.INSTANCE.getApiUrl(fodConfig.getUrl())) .build(); - return new FoDSessionDescriptor(urlConfig, createFoDTokenResponse(transportContext, urlConfig)); + return new FoDSessionDescriptor(urlConfig, createFoDTokenResponse(auth, urlConfig)); } - private FoDTokenCreateResponse createFoDTokenResponse(McpTransportContext transportContext, UrlConfig urlConfig) { - var clientId = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_ID); - var clientSecret = getOptionalHeader(transportContext, HEADER_FOD_CLIENT_SECRET); + private FoDTokenCreateResponse createFoDTokenResponse(ParsedAuthorization auth, UrlConfig urlConfig) { + var clientId = auth.fodClientId(); + var clientSecret = auth.fodClientSecret(); if ( StringUtils.isNotBlank(clientId) || StringUtils.isNotBlank(clientSecret) ) { return FoDOAuthHelper.createToken( urlConfig, new HttpMcpFoDClientCredentials( - getRequiredHeader(transportContext, HEADER_FOD_CLIENT_ID), - getRequiredHeader(transportContext, HEADER_FOD_CLIENT_SECRET) + auth.fodClientId(), + auth.fodClientSecret() ), DEFAULT_FOD_SCOPES ); @@ -188,9 +199,9 @@ private FoDTokenCreateResponse createFoDTokenResponse(McpTransportContext transp return FoDOAuthHelper.createToken( urlConfig, new HttpMcpFoDUserCredentials( - getRequiredHeader(transportContext, HEADER_FOD_TENANT), - getRequiredHeader(transportContext, HEADER_FOD_USER), - getRequiredHeader(transportContext, HEADER_FOD_PAT).toCharArray() + auth.fodTenant(), + auth.fodUser(), + auth.fodPat().toCharArray() ), DEFAULT_FOD_SCOPES ); @@ -219,6 +230,166 @@ private String getRequiredHeader(McpTransportContext transportContext, String he return value; } + private ParsedAuthorization parseAuthHeader(McpTransportContext transportContext) { + var product = config.getProduct(); + var headerName = getAuthHeaderName(product); + var headerValue = getRequiredHeader(transportContext, headerName); + var keyValues = parseAuthHeaderKeyValues(headerValue, headerName); + return switch (product) { + case ssc -> parseSscAuthorization(keyValues); + case fod -> parseFoDAuthorization(keyValues); + }; + } + + private String getAuthHeaderName(MCPServerHttpConfig.Product product) { + return switch (product) { + case ssc -> HEADER_AUTH_SSC; + case fod -> HEADER_AUTH_FOD; + }; + } + + private Map parseAuthHeaderKeyValues(String valuePart, String headerName) { + var result = new LinkedHashMap(); + for ( var segment : splitEscapedSegments(valuePart, headerName) ) { + var trimmedSegment = StringUtils.trimToNull(segment); + if ( trimmedSegment == null ) { + continue; + } + var separatorIndex = findUnescapedSeparator(trimmedSegment, '='); + if ( separatorIndex <= 0 || separatorIndex == trimmedSegment.length() - 1 ) { + throw new FcliSimpleException("Invalid %s header segment '%s'; expected key=value", headerName, trimmedSegment); + } + var key = StringUtils.trimToNull(unescapeHeaderValue(trimmedSegment.substring(0, separatorIndex), headerName)); + var value = StringUtils.trimToNull(unescapeHeaderValue(trimmedSegment.substring(separatorIndex + 1), headerName)); + if ( key == null || value == null ) { + throw new FcliSimpleException("Invalid %s header segment '%s'; expected key=value", headerName, trimmedSegment); + } + var normalizedKey = key.toLowerCase(Locale.ROOT); + if ( result.containsKey(normalizedKey) ) { + throw new FcliSimpleException("Duplicate %s header key: %s", headerName, key); + } + result.put(normalizedKey, value); + } + if ( result.isEmpty() ) { + throw new FcliSimpleException("%s header doesn't contain any key/value entries", headerName); + } + return result; + } + + private List splitEscapedSegments(String valuePart, String headerName) { + var result = new ArrayList(); + var current = new StringBuilder(); + var escaping = false; + for ( var i = 0; i < valuePart.length(); i++ ) { + var c = valuePart.charAt(i); + if ( escaping ) { + validateEscapeCharacter(c, headerName); + current.append('\\').append(c); + escaping = false; + } else if ( c == '\\' ) { + escaping = true; + } else if ( c == ';' ) { + result.add(current.toString()); + current.setLength(0); + } else { + current.append(c); + } + } + if ( escaping ) { + throw new FcliSimpleException("Invalid %s header value; trailing escape character", headerName); + } + result.add(current.toString()); + return result; + } + + private int findUnescapedSeparator(String value, char separator) { + var escaping = false; + for ( var i = 0; i < value.length(); i++ ) { + var c = value.charAt(i); + if ( escaping ) { + escaping = false; + } else if ( c == '\\' ) { + escaping = true; + } else if ( c == separator ) { + return i; + } + } + return -1; + } + + private String unescapeHeaderValue(String value, String headerName) { + var result = new StringBuilder(); + var escaping = false; + for ( var i = 0; i < value.length(); i++ ) { + var c = value.charAt(i); + if ( escaping ) { + validateEscapeCharacter(c, headerName); + result.append(c); + escaping = false; + } else if ( c == '\\' ) { + escaping = true; + } else { + result.append(c); + } + } + if ( escaping ) { + throw new FcliSimpleException("Invalid %s header value; trailing escape character", headerName); + } + return result.toString(); + } + + private void validateEscapeCharacter(char c, String headerName) { + if ( c != '\\' && c != ';' && c != '=' ) { + throw new FcliSimpleException("Invalid %s header escape sequence '\\%s'; supported escapes are \\\\, \\; and \\=", headerName, c); + } + } + + private ParsedAuthorization parseSscAuthorization(Map keyValues) { + var token = keyValues.get(SSC_TOKEN_KEY); + if ( StringUtils.isBlank(token) ) { + throw new FcliSimpleException("%s header requires key '%s'", HEADER_AUTH_SSC, SSC_TOKEN_KEY); + } + return new ParsedAuthorization( + MCPServerHttpConfig.Product.ssc, + token, + keyValues.get(SSC_SC_SAST_CLIENT_AUTH_TOKEN_KEY), + null, + null, + null, + null, + null + ); + } + + private ParsedAuthorization parseFoDAuthorization(Map keyValues) { + var clientId = keyValues.get(FOD_CLIENT_ID_KEY); + var clientSecret = keyValues.get(FOD_CLIENT_SECRET_KEY); + var tenant = keyValues.get(FOD_TENANT_KEY); + var user = keyValues.get(FOD_USER_KEY); + var pat = keyValues.get(FOD_PAT_KEY); + return new ParsedAuthorization( + MCPServerHttpConfig.Product.fod, + null, + null, + clientId, + clientSecret, + tenant, + user, + pat + ); + } + + private record ParsedAuthorization( + MCPServerHttpConfig.Product product, + String sscToken, + String scSastClientAuthToken, + String fodClientId, + String fodClientSecret, + String fodTenant, + String fodUser, + String fodPat + ) {} + private static final class HttpMcpFoDClientCredentials implements IFoDClientCredentials { private final String clientId; private final String clientSecret; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/AbstractMCPToolFcliRunner.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/AbstractMCPToolFcliRunner.java similarity index 89% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/AbstractMCPToolFcliRunner.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/AbstractMCPToolFcliRunner.java index 288a0875644..4002baee9c6 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/AbstractMCPToolFcliRunner.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/AbstractMCPToolFcliRunner.java @@ -10,10 +10,10 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlers; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import picocli.CommandLine.Model.CommandSpec; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/IMCPToolRunner.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/IMCPToolRunner.java similarity index 94% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/IMCPToolRunner.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/IMCPToolRunner.java index 40ac8538012..64f74975836 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/IMCPToolRunner.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/IMCPToolRunner.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPResourceFcliRunnerFunction.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPResourceFcliRunnerFunction.java similarity index 98% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPResourceFcliRunnerFunction.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPResourceFcliRunnerFunction.java index 7f0e407ac8a..23aff5a5fc6 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPResourceFcliRunnerFunction.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPResourceFcliRunnerFunction.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.List; import java.util.regex.Pattern; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java similarity index 87% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java index 81cadc2ec4c..9f93f1a869f 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java @@ -10,23 +10,28 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.CachingJobEventListener; -import com.fortify.cli.util._common.helper.IAsyncTask; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.CachingJobEventListener; +import com.fortify.cli.common.concurrent.job.IAsyncTask; /** * Thin adapter over {@link AsyncJobManager} for MCP tool use. Uses a * {@link CachingJobEventListener} to cache records, and registers background * futures with {@link MCPJobManager} for progress and cancellation support. + *

+ * This class treats the given job ID as the full cache/background identity. Transport- + * specific segregation, such as HTTP auth-context scoping, must already have been applied + * by the caller before invoking {@link #getCached(String)}, {@link #getJobToken(String)}, + * or {@link #getOrStartBackground(String, boolean, IAsyncTask)}. */ public class MCPToolAsyncJobManager { private final AsyncJobManager delegate; @@ -58,6 +63,10 @@ public MCPToolResult getCached(String jobId) { return builder.build(); } + public String getJobToken(String jobId) { + return jobTokens.get(jobId); + } + /** * Return completed result, or start/retrieve a background async job using the given task. * Returns null if already completed. Returns {@link InProgressEntry} if a @@ -69,6 +78,7 @@ public InProgressEntry getOrStartBackground(String jobId, boolean refresh, IAsyn } if (refresh) { cachingListener.remove(jobId); + jobTokens.remove(jobId); } if (delegate.isRunning(jobId)) { return new InProgressEntry(jobId, cachingListener, jobTokens.get(jobId)); @@ -87,7 +97,6 @@ public InProgressEntry getOrStartBackground(String jobId, boolean refresh, IAsyn var jobToken = jobManager.trackFuture("async_job", future, () -> cachingListener.getLoadedCount(jobId)); jobTokens.put(jobId, jobToken); - future.whenComplete((r, t) -> jobTokens.remove(jobId)); } return new InProgressEntry(jobId, cachingListener, jobTokens.get(jobId)); } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliPagedHelper.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java similarity index 77% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliPagedHelper.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java index b0fc45d9e01..ba0e83ae675 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliPagedHelper.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java @@ -10,13 +10,14 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.List; import java.util.Optional; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -27,6 +28,11 @@ * Shared paged-result logic for both command-based and function-based MCP tool runners. * Callers supply a {@link BackgroundStarter} that encapsulates how to start or resume * background record collection; this class owns the cache-check, wait, and result assembly. + *

+ * In HTTP mode, paged/background execution must be isolated per request auth context. + * This helper enforces that boundary by scoping semantic job IDs with the hashed auth + * scope stored in the current {@link FcliExecutionContextHolder} context before any cache + * or background-job lookup occurs. * * @author Ruud Senden */ @@ -64,24 +70,38 @@ static PageParams from(CallToolRequest request) { * resuming background collection via the supplied {@link BackgroundStarter}. */ CallToolResult run(String jobId, PageParams pageParams, BackgroundStarter starter) { + var scopedJobId = scopeJobId(jobId); try { - return tryGetCachedResult(jobId, pageParams) + return tryGetCachedResult(scopedJobId, pageParams) .or(() -> { try { - return tryGetInProgressResult(jobId, pageParams, starter); + return tryGetInProgressResult(scopedJobId, pageParams, starter); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("Interrupted while waiting for records", e); } }) - .orElseThrow(() -> new IllegalStateException("No result path succeeded for: " + jobId)); + .orElseThrow(() -> new IllegalStateException("No result path succeeded for: " + scopedJobId)); } catch (Exception e) { log.warn("Paged helper failed jobId='{}' offset={} limit={} error={}", - jobId, pageParams.offset, pageParams.limit, e.toString()); + scopedJobId, pageParams.offset, pageParams.limit, e.toString()); return MCPToolResult.fromError(e).asCallToolResult(); } } + /** + * Scope a semantic job ID by the current request auth context when present. + * + * In stateless HTTP MCP mode, two clients may invoke the same tool with identical + * arguments but different credentials. Using an auth-scoped key prevents those + * requests from sharing cached pages or background job state. Callers that interact + * with paged/background async state must use the scoped key consistently. + */ + static String scopeJobId(String jobId) { + var authScopeKey = FcliExecutionContextHolder.getMcpRequestAuthScopeKey(); + return authScopeKey == null ? jobId : authScopeKey + "|" + jobId; + } + private Optional tryGetCachedResult(String jobId, PageParams params) { if (params.refresh) { return Optional.empty(); @@ -90,7 +110,12 @@ private Optional tryGetCachedResult(String jobId, PageParams par .map(cached -> { log.debug("Completed job hit jobId='{}' offset={} limit={} total={}", jobId, params.offset, params.limit, cached.getRecords().size()); - return MCPToolResult.fromCompletedPagedResult(cached, params.offset, params.limit).asCallToolResult(); + return MCPToolResult.fromCompletedPagedResult( + cached, + params.offset, + params.limit, + jobManager.getAsyncJobManager().getJobToken(jobId) + ).asCallToolResult(); }); } @@ -108,7 +133,12 @@ private Optional tryGetInProgressResult( private Optional handleSyncJobCompleted(String jobId, PageParams params) { return Optional.ofNullable(jobManager.getAsyncJobManager().getCached(jobId)) - .map(cached -> MCPToolResult.fromCompletedPagedResult(cached, params.offset, params.limit).asCallToolResult()) + .map(cached -> MCPToolResult.fromCompletedPagedResult( + cached, + params.offset, + params.limit, + jobManager.getAsyncJobManager().getJobToken(jobId) + ).asCallToolResult()) .or(() -> Optional.of(MCPToolResult.fromError("Async job completed but no completed result found").asCallToolResult())); } @@ -148,7 +178,12 @@ private Optional checkForCompletedSuccessfully( .map(cached -> { log.debug("Returning COMPLETE paged result jobId='{}' offset={} limit={} loaded={} total={}", jobId, params.offset, params.limit, inProgress.getRecords().size(), cached.getRecords().size()); - return MCPToolResult.fromCompletedPagedResult(cached, params.offset, params.limit).asCallToolResult(); + return MCPToolResult.fromCompletedPagedResult( + cached, + params.offset, + params.limit, + jobManager.getAsyncJobManager().getJobToken(jobId) + ).asCallToolResult(); }) .or(() -> { log.warn("Background async job completed without completed entry jobId='{}'", jobId); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerAction.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerAction.java similarity index 94% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerAction.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerAction.java index 71d6a83d8fc..cf78522375e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerAction.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerAction.java @@ -10,17 +10,17 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.IMCPToolArgHandler; import com.fortify.cli.common.action.model.Action; import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.IMCPToolArgHandler; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerFunction.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunction.java similarity index 96% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerFunction.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunction.java index 95db9439636..79317af86b5 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerFunction.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunction.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.Map; import java.util.concurrent.Callable; @@ -18,10 +18,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.util.OutputHelper.Result; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerFunctionStreaming.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java similarity index 79% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerFunctionStreaming.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java index 39ce51d8c90..1b4007f72ed 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerFunctionStreaming.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java @@ -10,16 +10,17 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; +import com.fortify.cli.common.concurrent.job.task.AsyncTaskActionFunction; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util._common.helper.AsyncTaskActionFunction; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; @@ -48,7 +49,7 @@ public MCPToolFcliRunnerFunctionStreaming(ActionFunctionExecutor executor, MCPJo @Override public CallToolResult run(McpSyncServerExchange exchange, CallToolRequest request) { - var argsNode = buildArgsNode(request); + var argsNode = buildExecutionArgsNode(request); var jobId = toolName + ":" + argsNode; var pageParams = MCPToolFcliPagedHelper.PageParams.from(request); var producer = new AsyncTaskActionFunction(executor, argsNode); @@ -56,10 +57,13 @@ public CallToolResult run(McpSyncServerExchange exchange, CallToolRequest reques (key, refresh) -> jobManager.getAsyncJobManager().getOrStartBackground(key, refresh, producer)); } - private ObjectNode buildArgsNode(CallToolRequest request) { + static ObjectNode buildExecutionArgsNode(CallToolRequest request) { var argsNode = JsonHelper.getObjectMapper().createObjectNode(); if (request != null && request.arguments() != null) { for (Map.Entry entry : request.arguments().entrySet()) { + if ( isPagingArgument(entry.getKey()) ) { + continue; + } var value = entry.getValue(); if (value instanceof JsonNode jn) { argsNode.set(entry.getKey(), jn); @@ -70,4 +74,10 @@ private ObjectNode buildArgsNode(CallToolRequest request) { } return argsNode; } + + private static boolean isPagingArgument(String argName) { + return MCPToolArgHandlerPaging.ARG_OFFSET.equals(argName) + || MCPToolArgHandlerPaging.ARG_LIMIT.equals(argName) + || MCPToolArgHandlerPaging.ARG_REFRESH.equals(argName); + } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerHelper.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerHelper.java similarity index 95% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerHelper.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerHelper.java index bb7051fa3d4..d0c66e3f934 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerHelper.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerHelper.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -21,10 +21,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.concurrent.job.exec.FcliRunnerHelper; import com.fortify.cli.common.mcp.MCPDefaultValue; import com.fortify.cli.common.util.OutputHelper.Result; import com.fortify.cli.common.util.ReflectionHelper; -import com.fortify.cli.util._common.helper.FcliRunnerHelper; import picocli.CommandLine.Model.CommandSpec; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerPlainText.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerPlainText.java similarity index 92% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerPlainText.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerPlainText.java index 1c051c8bc88..88cbdaa9d45 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerPlainText.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerPlainText.java @@ -10,13 +10,13 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlers; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecords.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecords.java similarity index 93% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecords.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecords.java index 4395e15be0f..ed008a98a2c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecords.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecords.java @@ -10,15 +10,15 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.ArrayList; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlers; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecordsPaged.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java similarity index 89% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecordsPaged.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java index beb465cb7a6..92f0ff6e7c1 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecordsPaged.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java @@ -10,11 +10,11 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; -import com.fortify.cli.util._common.helper.AsyncTaskFcliCommand; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlers; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.common.concurrent.job.task.AsyncTaskFcliCommand; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolResult.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResult.java similarity index 96% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolResult.java rename to fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResult.java index 58cc03d03e8..7bacbca48af 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolResult.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResult.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.agent.mcp.helper.runner; import java.util.List; @@ -91,8 +91,12 @@ public static MCPToolResult fromRecords(Result result, List records) { * Create complete paged result once all records have been collected. */ public static MCPToolResult fromCompletedPagedResult(MCPToolResult plainResult, int offset, int limit) { + return fromCompletedPagedResult(plainResult, offset, limit, null); + } + + public static MCPToolResult fromCompletedPagedResult(MCPToolResult plainResult, int offset, int limit, String jobToken) { var allRecords = plainResult.getRecords(); - var pageInfo = PageInfo.complete(allRecords.size(), offset, limit); + var pageInfo = PageInfo.complete(allRecords.size(), offset, limit).toBuilder().jobToken(jobToken).build(); var endIndexExclusive = Math.min(offset+limit, allRecords.size()); List pageRecords = offset>=endIndexExclusive ? List.of() : allRecords.subList(offset, endIndexExclusive); return builder() diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties new file mode 100644 index 00000000000..dd367a66f1b --- /dev/null +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties @@ -0,0 +1,25 @@ +fcli.agent.usage.header = (PREVIEW) Manage AI assistant integrations +fcli.agent.usage.description = Manage AI-related functionality like MCP servers and skills. + +# fcli agent mcp +fcli.agent.mcp.usage.header = (PREVIEW) Manage fcli MCP server commands for AI assistants +fcli.agent.mcp.usage.description = Start fcli MCP servers for AI assistants, and generate HTTP MCP server config templates. +fcli.agent.mcp.start-stdio.usage.header = (PREVIEW) Start fcli MCP server on stdio for AI integration +fcli.agent.mcp.start-stdio.usage.description = Start the fcli MCP server over stdio. This command exposes fcli module commands and/or imported action functions as MCP tools to AI clients. +fcli.agent.mcp.start-stdio.module = Fcli module to expose through this MCP server instance. +fcli.agent.mcp.start-stdio.import = Action YAML files to import. Exported functions are registered as MCP tools or resources based on function metadata. +fcli.agent.mcp.start-stdio.work-threads = Number of worker threads used to execute MCP tool jobs concurrently. Increase for higher parallelism if AI invokes multiple tools simultaneously. +fcli.agent.mcp.start-stdio.progress-threads = Number of threads used for updating and tracking job progress for long-running jobs. +fcli.agent.mcp.start-stdio.job-safe-return = Maximum time to wait synchronously for a job result before returning an in_progress placeholder. Specify duration like 25s, 2m, 1h. +fcli.agent.mcp.start-stdio.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). +fcli.agent.mcp.start-stdio.async-bg-threads = Number of background threads for running async streaming jobs. Default: 2. + +fcli.agent.mcp.start-http.usage.header = (PREVIEW) Start import-only HTTP fcli MCP server for AI integration +fcli.agent.mcp.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. +fcli.agent.mcp.start-http.config = Path to HTTP MCP YAML config file. + +fcli.agent.mcp.create-http-config.usage.header = Generate a sample HTTP MCP server config file +fcli.agent.mcp.create-http-config.usage.description = Create a sample HTTP MCP config file for the selected product type. +fcli.agent.mcp.create-http-config.type = Product type for template generation: ssc or fod. +fcli.agent.mcp.create-http-config.config = Output path for the generated config file. Default: mcp-http-config.yaml. +fcli.agent.mcp.create-http-config.force = Overwrite an existing config file if it already exists. diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml new file mode 100644 index 00000000000..460281aab0a --- /dev/null +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml @@ -0,0 +1,8 @@ +port: 8080 +imports: + - actions/http-fod.yaml +fod: + url: https://api.ams.fortify.com + connectTimeout: 30s + socketTimeout: 10m + insecureModeEnabled: false diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml new file mode 100644 index 00000000000..af7a740ae26 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml @@ -0,0 +1,9 @@ +port: 8080 +imports: + - actions/http-ssc.yaml +ssc: + url: https://ssc.example.com + connectTimeout: 30s + socketTimeout: 10m + insecureModeEnabled: false + scSastClientAuthToken: ${#env('SSC_SAST_CLIENT_AUTH_TOKEN')} diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolverTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java similarity index 55% rename from fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolverTest.java rename to fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java index f4a974fe3fa..57c49f2e281 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/helper/http/MCPServerHttpSessionDescriptorResolverTest.java +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java @@ -10,8 +10,9 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.helper.http; +package com.fortify.cli.agent.mcp.helper.http; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -35,8 +36,8 @@ void createAuthCacheKeyHashesSscCredentials() { var resolver = new MCPServerHttpSessionDescriptorResolver(config); var cacheKey = resolver.createAuthCacheKey(transportContext(Map.of( - MCPServerHttpSessionDescriptorResolver.HEADER_SSC_TOKEN, List.of("ssc-token"), - MCPServerHttpSessionDescriptorResolver.HEADER_SC_SAST_CLIENT_AUTH_TOKEN, List.of("sast-token") + MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, + List.of("token=ssc-token;sc-sast-token=sast-token") ))); assertTrue(cacheKey.startsWith("ssc|")); @@ -53,8 +54,8 @@ void createAuthCacheKeyHashesFoDClientCredentials() { var resolver = new MCPServerHttpSessionDescriptorResolver(config); var cacheKey = resolver.createAuthCacheKey(transportContext(Map.of( - MCPServerHttpSessionDescriptorResolver.HEADER_FOD_CLIENT_ID, List.of("client-id"), - MCPServerHttpSessionDescriptorResolver.HEADER_FOD_CLIENT_SECRET, List.of("client-secret") + MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_FOD, + List.of("client-id=client-id;client-secret=client-secret") ))); assertTrue(cacheKey.startsWith("fod-client|")); @@ -71,14 +72,49 @@ void createAuthCacheKeyRejectsMixedFoDAuthModes() { var resolver = new MCPServerHttpSessionDescriptorResolver(config); var exception = assertThrows(FcliSimpleException.class, () -> resolver.createAuthCacheKey(transportContext(Map.of( - MCPServerHttpSessionDescriptorResolver.HEADER_FOD_CLIENT_ID, List.of("client-id"), - MCPServerHttpSessionDescriptorResolver.HEADER_FOD_CLIENT_SECRET, List.of("client-secret"), - MCPServerHttpSessionDescriptorResolver.HEADER_FOD_TENANT, List.of("tenant"), - MCPServerHttpSessionDescriptorResolver.HEADER_FOD_USER, List.of("user"), - MCPServerHttpSessionDescriptorResolver.HEADER_FOD_PAT, List.of("pat") + MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_FOD, + List.of("client-id=client-id;client-secret=client-secret;tenant=tenant;user=user;pat=pat") )))); - assertTrue(exception.getMessage().contains("Specify either FoD client headers")); + assertTrue(exception.getMessage().contains("Specify either FoD client keys")); + } + + @Test + void createAuthCacheKeySupportsEscapedSemicolonBackslashAndEquals() { + var config = new MCPServerHttpConfig(); + var sscConfig = new MCPServerHttpConfig.SscConfig(); + sscConfig.setUrl("https://ssc.example.com"); + config.setSsc(sscConfig); + var resolver = new MCPServerHttpSessionDescriptorResolver(config); + + var cacheKeyA = resolver.createAuthCacheKey(transportContext(Map.of( + MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, + List.of("token=abc\\;def\\=ghi\\\\jkl;sc-sast-token=secondary") + ))); + var cacheKeyB = resolver.createAuthCacheKey(transportContext(Map.of( + MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, + List.of("token=abc;sc-sast-token=secondary") + ))); + + assertTrue(cacheKeyA.startsWith("ssc|")); + assertFalse(cacheKeyA.contains("abc;def=ghi\\jkl")); + assertFalse(cacheKeyA.equals(cacheKeyB)); + } + + @Test + void createAuthCacheKeyRejectsInvalidEscapeSequence() { + var config = new MCPServerHttpConfig(); + var sscConfig = new MCPServerHttpConfig.SscConfig(); + sscConfig.setUrl("https://ssc.example.com"); + config.setSsc(sscConfig); + var resolver = new MCPServerHttpSessionDescriptorResolver(config); + + var exception = assertThrows(FcliSimpleException.class, () -> resolver.createAuthCacheKey(transportContext(Map.of( + MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, + List.of("token=abc\\n") + )))); + + assertEquals("Invalid X-AUTH-SSC header escape sequence '\\n'; supported escapes are \\\\, \\; and \\=", exception.getMessage()); } private McpTransportContext transportContext(Map> headers) { diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelperTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelperTest.java new file mode 100644 index 00000000000..458bc53330e --- /dev/null +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelperTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.mcp.helper.runner; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; + +class MCPToolFcliPagedHelperTest { + @Test + void scopeJobIdPrefixesCurrentAuthScopeKey() { + FcliExecutionContextHolder.pushNew(); + try { + FcliExecutionContextHolder.current().setMcpRequestAuthScopeKey("ssc|hashed-auth"); + + var scopedJobId = MCPToolFcliPagedHelper.scopeJobId("fcli_fn_sscAppListStream:{\"name\":\"demo\"}"); + + assertEquals("ssc|hashed-auth|fcli_fn_sscAppListStream:{\"name\":\"demo\"}", scopedJobId); + } finally { + FcliExecutionContextHolder.pop(); + } + } + + @Test + void scopeJobIdDiffersAcrossAuthScopesForSameSemanticJobId() { + var semanticJobId = "fcli_fn_sscAppListStream:{\"name\":\"demo\"}"; + String sscScopedJobId; + String fodScopedJobId; + + FcliExecutionContextHolder.pushNew(); + try { + FcliExecutionContextHolder.current().setMcpRequestAuthScopeKey("ssc|hashed-auth-1"); + sscScopedJobId = MCPToolFcliPagedHelper.scopeJobId(semanticJobId); + } finally { + FcliExecutionContextHolder.pop(); + } + + FcliExecutionContextHolder.pushNew(); + try { + FcliExecutionContextHolder.current().setMcpRequestAuthScopeKey("ssc|hashed-auth-2"); + fodScopedJobId = MCPToolFcliPagedHelper.scopeJobId(semanticJobId); + } finally { + FcliExecutionContextHolder.pop(); + } + + assertNotEquals(sscScopedJobId, fodScopedJobId); + assertEquals("ssc|hashed-auth-1|" + semanticJobId, sscScopedJobId); + assertEquals("ssc|hashed-auth-2|" + semanticJobId, fodScopedJobId); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java new file mode 100644 index 00000000000..1de453afaed --- /dev/null +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.mcp.helper.runner; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; + +class MCPToolFcliRunnerFunctionStreamingTest { + @Test + void buildExecutionArgsNodeExcludesPagingArguments() { + var request = new CallToolRequest( + "fcli_fn_sscAppListStream", + Map.of( + "pagination-offset", 20, + "refresh-cache", true, + "name", "demo" + ) + ); + + ObjectNode argsNode = MCPToolFcliRunnerFunctionStreaming.buildExecutionArgsNode(request); + + assertEquals("demo", argsNode.path("name").asText()); + assertFalse(argsNode.has("pagination-offset")); + assertFalse(argsNode.has("refresh-cache")); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java new file mode 100644 index 00000000000..bed44966ba1 --- /dev/null +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.mcp.helper.runner; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.json.JsonHelper; + +class MCPToolResultTest { + @Test + void fromCompletedPagedResultPreservesJobToken() { + var record = JsonHelper.getObjectMapper().createObjectNode().put("id", 1); + var plainResult = MCPToolResult.builder() + .exitCode(0) + .stderr("") + .records(List.of(record)) + .build(); + + var result = MCPToolResult.fromCompletedPagedResult(plainResult, 0, 20, "job-123"); + + assertEquals("job-123", result.getPagination().getJobToken()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPJobManagerTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java similarity index 97% rename from fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPJobManagerTest.java rename to fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java index f7c2acdbdb0..7d0e07a0363 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPJobManagerTest.java +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.unit; +package com.fortify.cli.agent.mcp.unit; import static org.junit.jupiter.api.Assertions.*; @@ -23,8 +23,8 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java similarity index 93% rename from fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java rename to fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java index 52b5b6c8738..df6c05cb19e 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPServerHttpConfigLoaderTest.java +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.unit; +package com.fortify.cli.agent.mcp.unit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -21,10 +21,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfig; +import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfigLoader; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.util.EnvHelper; -import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfig; -import com.fortify.cli.util.mcp_server.helper.http.MCPServerHttpConfigLoader; class MCPServerHttpConfigLoaderTest { @TempDir Path tempDir; diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPToolArgHandlersTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java similarity index 98% rename from fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPToolArgHandlersTest.java rename to fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java index d1a08e9444e..97d65f34651 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPToolArgHandlersTest.java +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.unit; +package com.fortify.cli.agent.mcp.unit; import static org.junit.jupiter.api.Assertions.*; @@ -18,7 +18,7 @@ import org.junit.jupiter.api.Test; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlers; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; import picocli.CommandLine; import picocli.CommandLine.Command; diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPToolFcliRunnerRecordsTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java similarity index 93% rename from fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPToolFcliRunnerRecordsTest.java rename to fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java index 4854a0e216a..e3be67fcaf9 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPToolFcliRunnerRecordsTest.java +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util.mcp_server.unit; +package com.fortify.cli.agent.mcp.unit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -22,10 +22,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.arg.MCPToolArgHandlers; -import com.fortify.cli.util.mcp_server.helper.mcp.runner.MCPToolFcliRunnerRecords; +import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerRecords; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; import picocli.CommandLine; import picocli.CommandLine.Command; diff --git a/fcli-core/fcli-app/build.gradle.kts b/fcli-core/fcli-app/build.gradle.kts index 92328e390ad..46a663419c4 100644 --- a/fcli-core/fcli-app/build.gradle.kts +++ b/fcli-core/fcli-app/build.gradle.kts @@ -7,7 +7,7 @@ plugins { // Inter-project dependencies val refs = listOf( - "fcliCommonRef","fcliActionRef","fcliAviatorRef","fcliConfigRef","fcliFoDRef","fcliSSCRef","fcliSCSastRef","fcliSCDastRef","fcliToolRef","fcliLicenseRef","fcliUtilRef" + "fcliCommonRef","fcliActionRef","fcliAgentRef","fcliAviatorRef","fcliConfigRef","fcliFoDRef","fcliSSCRef","fcliSCSastRef","fcliSCDastRef","fcliToolRef","fcliLicenseRef","fcliUtilRef" ) references@ for (r in refs) { val p = project.findProperty(r) as String? ?: continue@references @@ -43,7 +43,7 @@ val generateMCPReflectConfig = tasks.register("generateMCPReflectConfi inputs.files(configurations.runtimeClasspath, sourceSets.main.get().runtimeClasspath) outputs.file(outputFile) classpath(configurations.runtimeClasspath, sourceSets.main.get().runtimeClasspath) - mainClass.set("com.fortify.cli.util.mcp_server.helper.mcp.MCPReflectConfigGenerator") + mainClass.set("com.fortify.cli.agent.mcp.helper.MCPReflectConfigGenerator") args(outputFile.get().asFile.absolutePath) } diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java index 3d319940d45..239bdbdeffc 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.app._main.cli.cmd; +import com.fortify.cli.agent._main.cli.cmd.AgentCommands; import com.fortify.cli.app.FortifyCLIVersionProvider; import com.fortify.cli.aviator._main.cli.cmd.AviatorCommands; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; @@ -46,6 +47,7 @@ versionProvider = FortifyCLIVersionProvider.class, subcommands = { GenericActionCommands.class, + AgentCommands.class, AviatorCommands.class, ConfigCommands.class, FoDCommands.class, diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java index 8bdc1ca576a..b2915ef5bfa 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java @@ -39,6 +39,7 @@ public final class FcliExecutionContext { @Getter private final ObjectNode globalActionValues = new ObjectNode(JsonNodeFactory.instance, new ConcurrentHashMap<>()); @Getter private final UnirestContext unirestContext = new UnirestContext(); + @Getter private volatile String mcpRequestAuthScopeKey; // Encryption helper used for encrypt/decrypt in this execution. Default to global DEFAULT. private volatile EncryptionHelper encryptionHelper = EncryptionHelper.DEFAULT; // Set of absolute file paths that were saved using ephemeral encryption during this execution @@ -66,15 +67,20 @@ public void clearTransientSessionDescriptor(String type) { } } + public void setMcpRequestAuthScopeKey(String mcpRequestAuthScopeKey) { + this.mcpRequestAuthScopeKey = mcpRequestAuthScopeKey; + } + public String info() { - return String.format("FcliExecutionContext@%s(%d) actionGlobalValues@%s(%d) unirestContext@%s(%s) transientSessions=%d", + return String.format("FcliExecutionContext@%s(%d) actionGlobalValues@%s(%d) unirestContext@%s(%s) transientSessions=%d authScope=%s", Integer.toHexString(System.identityHashCode(this)), FcliExecutionContextHolder.stackDepth(), Integer.toHexString(System.identityHashCode(globalActionValues)), globalActionValues.size(), Integer.toHexString(System.identityHashCode(unirestContext)), unirestContext.getCachedInstanceCount(), - transientSessionDescriptors.size()); + transientSessionDescriptors.size(), + mcpRequestAuthScopeKey != null ? "set" : "unset"); } /** diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java index 9953fa35578..ad0528952b1 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java @@ -69,6 +69,16 @@ public static ISessionDescriptor getTransientSessionDescriptor(String type) { } return null; } + + public static String getMcpRequestAuthScopeKey() { + for ( var context : HOLDER.get() ) { + var authScopeKey = context.getMcpRequestAuthScopeKey(); + if ( authScopeKey != null ) { + return authScopeKey; + } + } + return null; + } /** Return the current stack depth. Useful for logging/troubleshooting. */ public static int stackDepth() { return HOLDER.get().size(); } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliModules.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliModules.java index e33c0473390..420eecc372e 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliModules.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliModules.java @@ -22,7 +22,7 @@ * */ public enum FcliModules { - ACTION, AVIATOR, CONFIG, FOD, LICENSE, SC_DAST, SC_SAST, SSC, TOOL, UTIL; + ACTION, AGENT, AVIATOR, CONFIG, FOD, LICENSE, SC_DAST, SC_SAST, SSC, TOOL, UTIL; @Override public String toString() { return name().toLowerCase().replace('_', '-'); } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncJobManager.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java similarity index 99% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncJobManager.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java index b78ff6e502e..f99fa21d642 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncJobManager.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job; import java.util.List; import java.util.Map; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CachingJobEventListener.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CachingJobEventListener.java similarity index 99% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CachingJobEventListener.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CachingJobEventListener.java index fb77b2731dc..de119e663f3 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CachingJobEventListener.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CachingJobEventListener.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job; import java.time.Duration; import java.util.List; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CollectingJobEventListener.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CollectingJobEventListener.java similarity index 98% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CollectingJobEventListener.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CollectingJobEventListener.java index 6c1b3088243..995d317435c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CollectingJobEventListener.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CollectingJobEventListener.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job; import java.time.Duration; import java.util.List; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CompositeJobEventListener.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CompositeJobEventListener.java similarity index 97% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CompositeJobEventListener.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CompositeJobEventListener.java index 1c5e2bedf4b..8e711d84db6 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/CompositeJobEventListener.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CompositeJobEventListener.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/IAsyncTask.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/IAsyncTask.java similarity index 96% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/IAsyncTask.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/IAsyncTask.java index 1c972aca9b7..d0ad933c93c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/IAsyncTask.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/IAsyncTask.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job; import java.util.function.Consumer; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/IJobEventListener.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/IJobEventListener.java similarity index 97% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/IJobEventListener.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/IJobEventListener.java index ed56b791b1b..e710acb624b 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/IJobEventListener.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/IJobEventListener.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job; import com.fasterxml.jackson.databind.JsonNode; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/cli/mixin/AsyncJobManagerMixin.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/cli/mixin/AsyncJobManagerMixin.java similarity index 92% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/cli/mixin/AsyncJobManagerMixin.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/cli/mixin/AsyncJobManagerMixin.java index 5144bc7b99e..509d753dded 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/cli/mixin/AsyncJobManagerMixin.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/cli/mixin/AsyncJobManagerMixin.java @@ -10,9 +10,9 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.cli.mixin; +package com.fortify.cli.common.concurrent.job.cli.mixin; -import com.fortify.cli.util._common.helper.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; import picocli.CommandLine.Option; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliExecutionResult.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliExecutionResult.java similarity index 98% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliExecutionResult.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliExecutionResult.java index 0731c2bab92..e5ad24aa42e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliExecutionResult.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliExecutionResult.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job.exec; import java.util.List; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliRunnerHelper.java similarity index 98% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliRunnerHelper.java index 432c305a7ec..093dfc22f6e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliRunnerHelper.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job.exec; import java.util.ArrayList; import java.util.Map; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncTaskActionFunction.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/task/AsyncTaskActionFunction.java similarity index 95% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncTaskActionFunction.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/task/AsyncTaskActionFunction.java index b6c3e200989..e55efc16719 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncTaskActionFunction.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/task/AsyncTaskActionFunction.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job.task; import java.util.function.Consumer; @@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.action.model.ActionStepRecordsForEach.IActionStepForEachProcessor; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; +import com.fortify.cli.common.concurrent.job.IAsyncTask; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.util.OutputHelper.Result; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncTaskFcliCommand.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/task/AsyncTaskFcliCommand.java similarity index 91% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncTaskFcliCommand.java rename to fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/task/AsyncTaskFcliCommand.java index a33ddd67dc7..11d1e8623b5 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/AsyncTaskFcliCommand.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/task/AsyncTaskFcliCommand.java @@ -10,12 +10,14 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.util._common.helper; +package com.fortify.cli.common.concurrent.job.task; import java.util.Map; import java.util.function.Consumer; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.common.concurrent.job.IAsyncTask; +import com.fortify.cli.common.concurrent.job.exec.FcliRunnerHelper; import com.fortify.cli.common.util.OutputHelper.Result; /** diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java index 299e50792d3..473f699e228 100644 --- a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.common.cli.util; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -61,6 +62,22 @@ void transientSessionDescriptorConvenienceSetterIndexesByType() { assertSame(descriptor, context.getTransientSessionDescriptor("dummy")); } + @Test + void mcpRequestAuthScopeKeyIsFoundInNestedParentContext() { + FcliExecutionContextHolder.pushNew(); + try { + FcliExecutionContextHolder.current().setMcpRequestAuthScopeKey("ssc|abc123"); + FcliExecutionContextHolder.pushNew(); + try { + assertEquals("ssc|abc123", FcliExecutionContextHolder.getMcpRequestAuthScopeKey()); + } finally { + FcliExecutionContextHolder.pop(); + } + } finally { + FcliExecutionContextHolder.pop(); + } + } + private static final class DummySessionDescriptor implements ISessionDescriptor { private final String type; diff --git a/fcli-core/fcli-util/build.gradle.kts b/fcli-core/fcli-util/build.gradle.kts index c1c815d0c49..06113e4af74 100644 --- a/fcli-core/fcli-util/build.gradle.kts +++ b/fcli-core/fcli-util/build.gradle.kts @@ -1,7 +1 @@ plugins { id("fcli.module-conventions") } - -val refs = listOf("fcliFoDRef", "fcliSSCRef") -references@ for (r in refs) { - val p = project.findProperty(r) as String? ?: continue@references - dependencies.add("implementation", project(p)) -} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerCommands.java index 4a33c88249e..7b0d1629015 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerCommands.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerCommands.java @@ -19,8 +19,7 @@ @Command( name = "mcp-server", subcommands = { - MCPServerStartCommand.class, - MCPServerStartHttpCommand.class + MCPServerStartDeprecatedCommand.class } ) public class MCPServerCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java new file mode 100644 index 00000000000..fb25d9882db --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.mcp_server.cli.cmd; + +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.mcp.MCPExclude; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.util.OutputHelper.OutputType; + +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Command; +import picocli.CommandLine.Unmatched; + +@Command(name = OutputHelperMixins.Start.CMD_NAME) +@MCPExclude +@Slf4j +public class MCPServerStartDeprecatedCommand extends AbstractRunnableCommand { + @Unmatched private List delegatedArgs; + + @Override + public Integer call() { + var cmd = "fcli agent mcp start-stdio"; + if ( delegatedArgs != null && !delegatedArgs.isEmpty() ) { + cmd += " " + String.join(" ", delegatedArgs); + } + log.warn("The 'fcli util mcp-server start' command is deprecated; please use 'fcli agent mcp start-stdio'"); + var result = FcliCommandExecutorFactory.builder() + .cmd(cmd) + .stdoutOutputType(OutputType.show) + .stderrOutputType(OutputType.show) + .createInvocationContext(true) + .onFail(r -> {}) + .build().create().execute(); + if ( result.getExitCode() != 0 && StringUtils.isNotBlank(result.getErr()) ) { + log.debug("Delegated command failed: {}", result.getErr()); + } + return result.getExitCode(); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java index 8a3be5daea0..46227094d34 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java @@ -18,12 +18,12 @@ import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; import com.fortify.cli.common.cli.util.StdioHelper; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.cli.mixin.AsyncJobManagerMixin; import com.fortify.cli.common.mcp.MCPExclude; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.util.DisableTest; import com.fortify.cli.common.util.DisableTest.TestType; -import com.fortify.cli.util._common.cli.mixin.AsyncJobManagerMixin; -import com.fortify.cli.util._common.helper.AsyncJobManager; import com.fortify.cli.util.rpc_server.helper.RPCMethodHandlerRegistry; import com.fortify.cli.util.rpc_server.helper.RPCServer; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java index 208c53efb30..4b21db41618 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java @@ -16,11 +16,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.concurrent.job.CachingJobEventListener; +import com.fortify.cli.common.concurrent.job.CompositeJobEventListener; +import com.fortify.cli.common.concurrent.job.IJobEventListener; import com.fortify.cli.common.util.DateTimePeriodHelper; import com.fortify.cli.common.util.DateTimePeriodHelper.Period; -import com.fortify.cli.util._common.helper.CachingJobEventListener; -import com.fortify.cli.util._common.helper.CompositeJobEventListener; -import com.fortify.cli.util._common.helper.IJobEventListener; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java index 97c19d56218..1ca68619639 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java @@ -13,9 +13,9 @@ package com.fortify.cli.util.rpc_server.helper; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.AsyncTaskFcliCommand; -import com.fortify.cli.util._common.helper.CollectingJobEventListener; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.CollectingJobEventListener; +import com.fortify.cli.common.concurrent.job.task.AsyncTaskFcliCommand; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFnCall.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFnCall.java index 4621e207817..72572cb49f1 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFnCall.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFnCall.java @@ -17,10 +17,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.CollectingJobEventListener; +import com.fortify.cli.common.concurrent.job.task.AsyncTaskActionFunction; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.AsyncTaskActionFunction; -import com.fortify.cli.util._common.helper.CollectingJobEventListener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobCancel.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobCancel.java index 13c60b058b6..18de71731c1 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobCancel.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobCancel.java @@ -14,8 +14,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util._common.helper.AsyncJobManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetPage.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetPage.java index 14afa9494ec..cd82d6c9e7b 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetPage.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetPage.java @@ -15,8 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.concurrent.job.CachingJobEventListener; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util._common.helper.CachingJobEventListener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetStatus.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetStatus.java index 29b9c50dfff..9c9fbf347f0 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetStatus.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetStatus.java @@ -13,9 +13,9 @@ package com.fortify.cli.util.rpc_server.helper; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.CachingJobEventListener; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.CachingJobEventListener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobList.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobList.java index 5e72fd07947..bfc0981fd49 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobList.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobList.java @@ -15,8 +15,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util._common.helper.AsyncJobManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobRemove.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobRemove.java index 85ccc61eed4..fb934ab6048 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobRemove.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobRemove.java @@ -13,9 +13,9 @@ package com.fortify.cli.util.rpc_server.helper; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.CachingJobEventListener; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.CachingJobEventListener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java index dad7097e981..d61a06d0792 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java @@ -21,8 +21,8 @@ import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.cli.util.FcliExecutionContext; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.CachingJobEventListener; +import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import com.fortify.cli.common.concurrent.job.CachingJobEventListener; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCPushJobEventListener.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCPushJobEventListener.java index 27ca8bbcdd7..9f1e9f5e073 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCPushJobEventListener.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCPushJobEventListener.java @@ -16,7 +16,7 @@ import java.util.List; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.util._common.helper.IJobEventListener; +import com.fortify.cli.common.concurrent.job.IJobEventListener; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCWaitHelper.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCWaitHelper.java index 923d97b0983..ad42175a54c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCWaitHelper.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCWaitHelper.java @@ -14,8 +14,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fortify.cli.common.concurrent.job.CollectingJobEventListener; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.util._common.helper.CollectingJobEventListener; import lombok.extern.slf4j.Slf4j; diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index d66cc789246..9e3909f8057 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -48,56 +48,9 @@ fcli.util.crypto.decrypt.usage.header = Decrypt a value. fcli.util.crypto.decrypt.prompt = Value to decrypt: # fcli util mcp-server -fcli.util.mcp-server.usage.header = (PREVIEW) Manage fcli MCP server for LLM integration -fcli.util.mcp-server.start.usage.header = (PREVIEW) Start fcli MCP server for LLM integration -fcli.util.mcp-server.start.usage.description = The fcli MCP (Model Context Protocol) server allows an \ - LLM to interact with Fortify products by executing fcli commands and actions. Contrary to most other \ - fcli commands, the 'fcli util mcp-server start' command is not meant to be invoked from the command \ - line; instead, an LLM client can run this command to start the fcli MCP server and then interact with \ - this MCP server through stdio (standard input/output). For more information about MCP, please see \ - %nhttps://modelcontextprotocol.io/.\ - %n%n\ - MCP server configuration may vary across IDEs and other LLM clients; the following snippet shows how \ - to configure two MCP servers covering respectively 'fcli ssc' and 'fcli sc-sast' commands in IDEs like \ - Visual Studio Code or Eclipse: \ - %n\ - %n{\ - %n "servers": {\ - %n "fcli-ssc": {\ - %n "type": "stdio",\ - %n "command": "/path/to/fcli",\ - %n "args": ["util","mcp-server","start","--module=ssc"]\ - %n },\ - %n "fcli-sc-sast": {\ - %n "type": "stdio",\ - %n "command": "/path/to/fcli",\ - %n "args": ["util","mcp-server","start","--module=sc-sast"]\ - %n }\ - %n }\ - %n}\ - %n%n\ - By default, the fcli MCP server will generate an MCP tool definition for every individual fcli command in the specified \ - module, excluding:%n\ - %n- Non-runnable (container) commands, as these are not relevant in LLM context \ - %n- Potentially disruptive or destructive commands like (most) update, delete, clear, and purge commands \ - %n- Any command that requires sensitive data like credentials to be specified, to avoid users from entering their sensitive data in an LLM system \ - %n%n\ - Note that the latter means that LLMs cannot run fcli session login commands; you'll need to have one or more active \ - sessions for each of the products that you want to interact with through the LLM. In your LLM chat, you can ask for a \ - specific session to be used for executing a given operation. %n\ - %n\ - For now, only commands from product-related fcli modules like 'fod' or 'ssc' can be exposed as MCP tools. If you \ - require any of the other fcli modules like 'fcli tool' or 'fcli util' to be exposed as MCP tools, we can consider \ - this for a future release.%n\ - %n\ - As LLMs pose limits on the number of enabled tools, and clients often allow for easily enabling or disabling all \ - tools provided by a given MCP server, each fcli MCP server instance only supports a single fcli module. For example, \ - you can configure separate MCP servers for ssc, sc-sast, and sc-dast modules, but keep the sc-dast MCP tools disabled \ - until you need them.%n\ - %n\ - Fcli action functions can be registered as MCP tools using the --import option, with those functions defining for example \ - custom REST operations or complete workflows for executing multiple fcli commands in a specific sequence. This allows for \ - easily exposing custom operations as MCP tools without needing to implement a custom MCP server outside of fcli. +fcli.util.mcp-server.usage.header = (PREVIEW, DEPRECATED) Legacy MCP server compatibility commands +fcli.util.mcp-server.start.usage.header = (PREVIEW, DEPRECATED) Start legacy fcli MCP server command +fcli.util.mcp-server.start.usage.description = This command is deprecated and kept only for backward compatibility. Use 'fcli agent mcp start-stdio' instead. All given arguments are forwarded to the new command. fcli.util.mcp-server.start.module = Fcli module to expose through this MCP server instance. fcli.util.mcp-server.start.import = Action YAML files to import. Exported functions are registered as MCP tools or resources based on function metadata. fcli.util.mcp-server.start.work-threads = Number of worker threads used to execute MCP tool jobs concurrently. Increase for higher parallelism if LLM invokes multiple tools simultaneously. diff --git a/gradle.properties b/gradle.properties index 4adf83d5896..c7109f7f98a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,6 +4,7 @@ # needed, the corresponding project directory path can be obtained through the # getRefDir(ref) function. fcliAppRef=:fcli-core:fcli-app +fcliAgentRef=:fcli-core:fcli-agent fcliAviatorRef=:fcli-core:fcli-aviator fcliAviatorCommonRef=:fcli-core:fcli-aviator-common fcliCommonRef=:fcli-core:fcli-common From 56e96d2b5afa8bb96c47c7219c63bff0c895ab0e Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 8 May 2026 14:37:32 +0200 Subject: [PATCH 09/55] chore: Add functional tests --- .github/copilot-instructions.md | 2 +- .../cli/ftest/core/MCPServerHttpSpec.groovy | 282 ++++++++++++++++++ .../server-import-http-fod-functions.yaml | 24 ++ .../server-import-http-ssc-functions.yaml | 24 ++ 4 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerHttpSpec.groovy create mode 100644 fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/server-import-http-fod-functions.yaml create mode 100644 fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/server-import-http-ssc-functions.yaml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9323f1c1248..8ebfcbf5eaf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,7 +19,7 @@ Fcli is a modular CLI tool for interacting with Fortify products (FoD, SSC, Scan - **Validation:** Use `get_errors` tool first, then full Gradle build to catch warnings - **Testing:** - Unit tests: `src/test`; command structure validated in `FortifyCLITest` - - Functional tests: `fcli-core/fcli-functional-test` module; run with `./gradlew :fcli-core:fcli-functional-test:test` + - Functional tests: `fcli-other/fcli-functional-test` module; run with `./gradlew :fcli-other:fcli-functional-test:ftest` ## Code Conventions diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerHttpSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerHttpSpec.groovy new file mode 100644 index 00000000000..6154d9a4beb --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerHttpSpec.groovy @@ -0,0 +1,282 @@ +package com.fortify.cli.ftest.core + +import java.net.ServerSocket +import java.net.Socket +import java.net.http.HttpRequest +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.TimeUnit + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fortify.cli.ftest._common.Fcli +import com.fortify.cli.ftest._common.spec.FcliBaseSpec +import com.fortify.cli.ftest._common.spec.Prefix +import com.fortify.cli.ftest._common.spec.TempDir +import com.fortify.cli.ftest._common.spec.TestResource + +import io.modelcontextprotocol.client.McpClient +import io.modelcontextprotocol.client.McpSyncClient +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper +import io.modelcontextprotocol.spec.McpSchema +import spock.lang.IgnoreIf +import spock.lang.Requires +import spock.lang.Shared + +@IgnoreIf({ !sys["ft.fcli"] || sys["ft.fcli"] == "build" }) +@Prefix("core.mcp-server.http") +class MCPServerHttpSpec extends FcliBaseSpec { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + + @Shared @TempDir("core/mcp-http") String tempDir + @Shared @TestResource("runtime/actions/server-import-functions.yaml") String commonImportActionPath + @Shared @TestResource("runtime/actions/server-import-http-ssc-functions.yaml") String sscImportActionPath + @Shared @TestResource("runtime/actions/server-import-http-fod-functions.yaml") String fodImportActionPath + + @Requires({ + System.getProperty('ft.ssc.url') && + (System.getProperty('ft.ssc.token') || + (System.getProperty('ft.ssc.user') && System.getProperty('ft.ssc.password'))) + }) + def "http mcp supports ssc auth-backed tools"() { + given: + def auth = createSscAuth() + def config = createSscConfig() + def handle = startHttpClient(config, "X-AUTH-SSC", auth.headerValue as String) + + when: + def toolNames = handle.client.listTools().tools().collect { it.name() } as Set + def productResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_sscRestCount", [:])) + def productText = getText(productResult) + def streamingResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_generateItems", [items: [0, 1, 2]])) + def streamingText = getText(streamingResult) + + then: + toolNames.containsAll(["fcli_fn_echo", "fcli_fn_generateItems", "fcli_fn_sscRestCount", "fcli_mcp_job"]) + productText.contains("SSC-REST-OK count=") + !productResult.isError() + streamingText.contains("item-0") + streamingText.contains("item-1") + streamingText.contains("item-2") + !streamingResult.isError() + + cleanup: + handle?.close() + auth?.cleanup?.call() + } + + @Requires({ + System.getProperty('ft.fod.url') && + System.getProperty('ft.fod.tenant') && + System.getProperty('ft.fod.user') && + System.getProperty('ft.fod.password') + }) + def "http mcp supports fod auth-backed tools"() { + given: + def config = createFoDConfig() + def handle = startHttpClient(config, "X-AUTH-FOD", createFoDAuthHeaderValue()) + + when: + def toolNames = handle.client.listTools().tools().collect { it.name() } as Set + def productResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_fodRestCount", [:])) + def productText = getText(productResult) + def streamingResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_generateItems", [items: [3, 4, 5]])) + def streamingText = getText(streamingResult) + + then: + toolNames.containsAll(["fcli_fn_echo", "fcli_fn_generateItems", "fcli_fn_fodRestCount", "fcli_mcp_job"]) + productText.contains("FOD-REST-OK count=") + !productResult.isError() + streamingText.contains("item-3") + streamingText.contains("item-4") + streamingText.contains("item-5") + !streamingResult.isError() + + cleanup: + handle?.close() + } + + private HttpServerConfig createSscConfig() { + def port = getFreePort() + def configPath = Path.of(tempDir, "mcp-http-ssc-${port}.yaml") + def scSastClientAuthToken = System.getProperty("ft.ssc.client-auth-token") + def config = new StringBuilder() + .append("port: ${port}\n") + .append("imports:\n") + .append(" - ${commonImportActionPath}\n") + .append(" - ${sscImportActionPath}\n") + .append("ssc:\n") + .append(" url: ${System.getProperty('ft.ssc.url')}\n") + .append(" connectTimeout: 30s\n") + .append(" socketTimeout: 10m\n") + .append(" insecureModeEnabled: false\n") + if ( scSastClientAuthToken ) { + config.append(" scSastClientAuthToken: ${scSastClientAuthToken}\n") + } + Files.writeString(configPath, config.toString()) + return new HttpServerConfig(configPath, port) + } + + private HttpServerConfig createFoDConfig() { + def port = getFreePort() + def configPath = Path.of(tempDir, "mcp-http-fod-${port}.yaml") + def config = """ + port: ${port} + imports: + - ${commonImportActionPath} + - ${fodImportActionPath} + fod: + url: ${System.getProperty('ft.fod.url')} + connectTimeout: 30s + socketTimeout: 10m + insecureModeEnabled: false + """.stripIndent() + Files.writeString(configPath, config) + return new HttpServerConfig(configPath, port) + } + + private Map createSscAuth() { + def configuredToken = System.getProperty("ft.ssc.token") + if ( configuredToken ) { + return [ + headerValue: createSscAuthHeaderValue(configuredToken), + cleanup: {} + ] + } + + def user = System.getProperty("ft.ssc.user") + def password = System.getProperty("ft.ssc.password") + def tokenName = "HttpMcpFtest-${System.currentTimeMillis()}" + def result = Fcli.run([ + "ssc", "ac", "create-token", tokenName, + "--expire-in=5m", + "--user=${user}", + "--password=${password}", + "-o", "json" + ]) + def tokenData = OBJECT_MAPPER.readTree(result.stdout.join("\n")) + def restToken = tokenData.get("restToken").asText() + return [ + headerValue: createSscAuthHeaderValue(restToken), + cleanup: { + Fcli.run([ + "ssc", "ac", "revoke-token", restToken, + "--user=${user}", + "--password=${password}" + ]) + } + ] + } + + private String createSscAuthHeaderValue(String restToken) { + def values = ["token=${escapeAuthHeaderValue(restToken)}"] + def scSastClientAuthToken = System.getProperty("ft.ssc.client-auth-token") + if ( scSastClientAuthToken ) { + values << "sc-sast-token=${escapeAuthHeaderValue(scSastClientAuthToken)}" + } + return values.join(";") + } + + private String createFoDAuthHeaderValue() { + return [ + "tenant=${escapeAuthHeaderValue(System.getProperty('ft.fod.tenant'))}", + "user=${escapeAuthHeaderValue(System.getProperty('ft.fod.user'))}", + "pat=${escapeAuthHeaderValue(System.getProperty('ft.fod.password'))}" + ].join(";") + } + + private HttpClientHandle startHttpClient(HttpServerConfig config, String authHeaderName, String authHeaderValue) { + def process = startHttpServer(config) + def transport = HttpClientStreamableHttpTransport.builder("http://127.0.0.1:${config.port}") + .endpoint("/mcp") + .connectTimeout(Duration.ofSeconds(10)) + .jsonMapper(new JacksonMcpJsonMapper(new ObjectMapper())) + .customizeRequest({ HttpRequest.Builder builder -> builder.header(authHeaderName, authHeaderValue) }) + .build() + def client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(30)) + .initializationTimeout(Duration.ofSeconds(60)) + .build() + client.initialize() + return new HttpClientHandle(client, process) + } + + private Process startHttpServer(HttpServerConfig config) { + def cmd = Fcli.buildExternalCommand(["agent", "mcp", "start-http", "--config", config.path.toString()]) + def process = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .start() + waitForServerStartup(process, config.port) + return process + } + + private static void waitForServerStartup(Process process, int port) { + def deadline = System.currentTimeMillis() + 30_000 + while ( System.currentTimeMillis() < deadline ) { + if ( !process.alive() ) { + throw new RuntimeException("HTTP MCP server exited before startup completed (exit code ${process.exitValue()})") + } + if ( isPortOpen(port) ) { + return + } + Thread.sleep(100) + } + process.destroyForcibly() + process.waitFor(5, TimeUnit.SECONDS) + throw new RuntimeException("HTTP MCP server did not start within 30 seconds on port ${port}") + } + + private static boolean isPortOpen(int port) { + try { + new Socket("127.0.0.1", port).close() + return true + } catch ( IOException ignored ) { + return false + } + } + + private static int getFreePort() { + new ServerSocket(0).withCloseable { it.localPort } + } + + private static String escapeAuthHeaderValue(String value) { + return value.replace("\\", "\\\\").replace(";", "\\;").replace("=", "\\=") + } + + private static String getText(McpSchema.CallToolResult result) { + return result.content().findAll { it instanceof McpSchema.TextContent } + .collect { ((McpSchema.TextContent)it).text() } + .join("") + } + + private static final class HttpServerConfig { + private final Path path + private final int port + + private HttpServerConfig(Path path, int port) { + this.path = path + this.port = port + } + } + + private static final class HttpClientHandle implements Closeable { + private final McpSyncClient client + private final Process process + + private HttpClientHandle(McpSyncClient client, Process process) { + this.client = client + this.process = process + } + + @Override + void close() throws IOException { + try { + client?.closeGracefully() + } finally { + process?.destroyForcibly() + process?.waitFor(5, TimeUnit.SECONDS) + } + } + } +} \ No newline at end of file diff --git a/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/server-import-http-fod-functions.yaml b/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/server-import-http-fod-functions.yaml new file mode 100644 index 00000000000..843143b4da7 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/server-import-http-fod-functions.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: ftest +usage: + header: Ftest server import HTTP functions (FoD) + description: Session-backed FoD function for HTTP MCP functional tests + +functions: + fodRestCount: + description: Run a FoD REST call through with.product using the transient HTTP session + return: ${_result} + steps: + - with.product: + name: fod + do: + - rest.call: + apps: + target: fod + uri: /api/v3/applications?limit=1 + on.success: + - var.set: + _result: ${'FOD-REST-OK count=' + apps_raw.totalCount} + +steps: [] \ No newline at end of file diff --git a/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/server-import-http-ssc-functions.yaml b/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/server-import-http-ssc-functions.yaml new file mode 100644 index 00000000000..7efff97beb6 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/server-import-http-ssc-functions.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: ftest +usage: + header: Ftest server import HTTP functions (SSC) + description: Session-backed SSC function for HTTP MCP functional tests + +functions: + sscRestCount: + description: Run an SSC REST call through with.product using the transient HTTP session + return: ${_result} + steps: + - with.product: + name: ssc + do: + - rest.call: + versions: + target: ssc + uri: /api/v1/projectVersions?limit=1 + on.success: + - var.set: + _result: ${'SSC-REST-OK count=' + versions_raw.count} + +steps: [] \ No newline at end of file From 2c6fcaf0986d658f8aaf680170f6af026a4c7498 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 8 May 2026 15:07:41 +0200 Subject: [PATCH 10/55] chore: Various improvements --- .../mcp/cli/cmd/AgentMCPStartHttpCommand.java | 5 ++- .../MCPImportedActionMcpSpecsFactory.java | 7 +++-- .../JdkHttpServerMcpStatelessTransport.java | 12 ------- ...CPServerHttpSessionDescriptorResolver.java | 20 ++++++++++++ .../action/runner/ActionFunctionExecutor.java | 31 ++++++++++++++----- 5 files changed, 49 insertions(+), 26 deletions(-) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index ac83772c04e..a50b1b5e423 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -26,7 +26,6 @@ import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfigLoader; import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpSessionDescriptorResolver; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; -import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.exception.FcliSimpleException; @@ -75,9 +74,9 @@ public Integer call() throws Exception { asyncJobManager ); - var sharedFunctionContext = new FcliExecutionContext(); - var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, sharedFunctionContext); var sessionDescriptorResolver = new MCPServerHttpSessionDescriptorResolver(config); + var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, + () -> sessionDescriptorResolver.getOrCreateFunctionContext(FcliExecutionContextHolder.getMcpRequestAuthScopeKey())); var toolSpecs = new ArrayList(); var resourceTemplateSpecs = new ArrayList(); for ( var importPath : config.getResolvedImportPaths() ) { diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java index 95430f9f6d6..c18b469aa36 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java @@ -15,6 +15,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.function.Supplier; import com.fasterxml.jackson.databind.JsonNode; import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; @@ -39,7 +40,7 @@ @RequiredArgsConstructor public class MCPImportedActionMcpSpecsFactory { private final MCPJobManager jobManager; - private final FcliExecutionContext sharedFunctionContext; + private final Supplier functionContextSupplier; public ImportedSpecs create(Path importFile) { var action = ActionLoaderHelper.load( @@ -65,7 +66,7 @@ public ImportedSpecs create(Path importFile) { } private SyncToolSpecification createToolSpec(com.fortify.cli.common.action.model.Action action, String functionName, ActionFunction function) { - var executor = new ActionFunctionExecutor(action, function, sharedFunctionContext); + var executor = new ActionFunctionExecutor(action, function, functionContextSupplier); var toolName = "fcli_fn_" + functionName.replace('-', '_'); var schema = buildFunctionArgsSchema(function); var description = function.getDescription() != null ? function.getDescription() : functionName; @@ -95,7 +96,7 @@ private SyncResourceTemplateSpecification createResourceTemplateSpec(com.fortify var uriTemplate = getMetaString(resourceMeta, "uri-template"); var name = getMetaString(resourceMeta, "name"); var mimeType = getMetaString(resourceMeta, "mime-type"); - var executor = new ActionFunctionExecutor(action, function, sharedFunctionContext); + var executor = new ActionFunctionExecutor(action, function, functionContextSupplier); var template = ResourceTemplate.builder() .uriTemplate(uriTemplate) .name(name != null ? name : functionName) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java index e1cf3e5f764..307808dd0ac 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -15,7 +15,6 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -89,8 +88,6 @@ private void handleExchange(HttpExchange exchange) throws IOException { return; } - log.info("[TEMP DEBUG] Incoming MCP HTTP request headers: {}", formatHeadersForLog(exchange.getRequestHeaders())); - var accept = getFirstHeader(exchange, "Accept"); if ( accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM)) ) { sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) @@ -177,13 +174,4 @@ private void sendEmpty(HttpExchange exchange, int status) throws IOException { exchange.close(); } - private String formatHeadersForLog(Map> headers) { - if ( headers == null || headers.isEmpty() ) { - return "{}"; - } - return headers.entrySet().stream() - .sorted(Comparator.comparing(Map.Entry::getKey, String.CASE_INSENSITIVE_ORDER)) - .map(e -> e.getKey() + "=" + e.getValue()) - .collect(Collectors.joining(", ", "{", "}")); - } } \ No newline at end of file diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 0060725ee38..515b07a9333 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -26,6 +26,7 @@ import org.apache.commons.lang3.StringUtils; +import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.rest.unirest.config.UrlConfig; import com.fortify.cli.common.session.helper.ISessionDescriptor; @@ -70,6 +71,14 @@ protected boolean removeEldestEntry(Map.Entry eldest return size() > MAX_SESSION_DESCRIPTOR_CACHE_SIZE; } }; + private final Map functionContextCache = new LinkedHashMap<>(16, 0.75f, true) { + private static final long serialVersionUID = 1L; + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_SESSION_DESCRIPTOR_CACHE_SIZE; + } + }; public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext transportContext) { var cacheKey = createAuthCacheKey(transportContext); @@ -78,6 +87,17 @@ public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext trans } } + /** + * Returns the {@link FcliExecutionContext} for the given auth scope key, creating one + * on first use. Each distinct auth identity gets its own context so that + * {@code global.*} action variables are not shared across different callers. + */ + public FcliExecutionContext getOrCreateFunctionContext(String authScopeKey) { + synchronized (functionContextCache) { + return functionContextCache.computeIfAbsent(authScopeKey, ignored -> new FcliExecutionContext()); + } + } + public String getAuthScopeKey(McpTransportContext transportContext) { return createAuthCacheKey(transportContext); } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java index a7e9fb59f56..ddad6ea49e8 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java @@ -12,6 +12,8 @@ */ package com.fortify.cli.common.action.runner; +import java.util.function.Supplier; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.action.model.Action; @@ -27,22 +29,35 @@ * {@link ActionRunnerContextLocal} per invocation, builds the args ObjectNode, * and delegates to {@link ActionFunctionSpelFunctions#call(String, Object...)}. *

- * All executors created for the same server share a single - * {@link FcliExecutionContext} so that {@code globalActionValues} persist across - * invocations. The shared context is pushed onto the calling thread's stack - * during execution and popped afterwards. + * A {@link Supplier} of {@link FcliExecutionContext} is evaluated on each invocation + * to determine which context to push. For RPC/stdio servers this supplier typically + * returns the same shared instance (so {@code globalActionValues} persist across + * invocations). For HTTP servers the supplier can return a per-auth-scope context + * so that different auth identities have isolated {@code globalActionValues}. *

* Used by MCP/RPC server implementations to invoke exported functions. */ public final class ActionFunctionExecutor { private final Action action; private final ActionFunction function; - private final FcliExecutionContext sharedContext; + private final Supplier contextSupplier; - public ActionFunctionExecutor(Action action, ActionFunction function, FcliExecutionContext sharedContext) { + /** + * Create an executor that resolves its execution context via a supplier on + * each invocation. + */ + public ActionFunctionExecutor(Action action, ActionFunction function, Supplier contextSupplier) { this.action = action; this.function = function; - this.sharedContext = sharedContext; + this.contextSupplier = contextSupplier; + } + + /** + * Convenience constructor that wraps a fixed, shared {@link FcliExecutionContext} + * so that all invocations use the same context (original behaviour for RPC/stdio). + */ + public ActionFunctionExecutor(Action action, ActionFunction function, FcliExecutionContext sharedContext) { + this(action, function, () -> sharedContext); } public Action getAction() { @@ -62,7 +77,7 @@ public ActionFunction getFunction() { * For streaming functions: an IActionStepForEachProcessor. */ public Object execute(ObjectNode argsNode) { - FcliExecutionContextHolder.push(sharedContext); + FcliExecutionContextHolder.push(contextSupplier.get()); try { var config = ActionRunnerConfig.builder() .action(action) From 7cf377b5c7452afe3c1bc5c33025dddcd28a9b83 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 8 May 2026 15:27:31 +0200 Subject: [PATCH 11/55] chore: Update usage help --- .../com/fortify/cli/agent/i18n/AgentMessages.properties | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties index dd367a66f1b..aea06e3ac0e 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties @@ -15,8 +15,13 @@ fcli.agent.mcp.start-stdio.progress-interval = Interval between internal progres fcli.agent.mcp.start-stdio.async-bg-threads = Number of background threads for running async streaming jobs. Default: 2. fcli.agent.mcp.start-http.usage.header = (PREVIEW) Start import-only HTTP fcli MCP server for AI integration -fcli.agent.mcp.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. -fcli.agent.mcp.start-http.config = Path to HTTP MCP YAML config file. +fcli.agent.mcp.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. Generate a sample config file with 'fcli agent mcp create-http-config --type ' and customize the generated YAML for your environment. The server listens for MCP POST requests on the /mcp endpoint. Each request must include the product-specific auth header as semicolon-separated key=value pairs; escape literal '\\', ';', or '=' characters as '\\\\', '\\;', or '\\='.\ + %n%nAUTH HEADERS (per HTTP request):\ + %n- SSC mode: X-AUTH-SSC: token=[;sc-sast-token=]\ + %n- FoD mode, user/PAT auth: X-AUTH-FOD: tenant=;user=;pat=\ + %n- FoD mode, client auth: X-AUTH-FOD: client-id=;client-secret=\ + %nExactly one FoD auth mode must be specified per request. +fcli.agent.mcp.start-http.config = Path to HTTP MCP YAML config file. Generate a template with 'fcli agent mcp create-http-config'. fcli.agent.mcp.create-http-config.usage.header = Generate a sample HTTP MCP server config file fcli.agent.mcp.create-http-config.usage.description = Create a sample HTTP MCP config file for the selected product type. From e4ae4efb5ede151f4ba56d7f984fe43d9dd3eb27 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 8 May 2026 17:20:45 +0200 Subject: [PATCH 12/55] chore: Refactor execution state management --- .../mcp/cli/cmd/AgentMCPStartHttpCommand.java | 9 ++- .../cli/cmd/AgentMCPStartStdioCommand.java | 35 ++++++++-- .../cli/agent/mcp/helper/MCPJobManager.java | 32 +++++++-- ...CPServerHttpSessionDescriptorResolver.java | 41 +++++++++-- .../helper/runner/MCPToolAsyncJobManager.java | 38 +++++----- .../helper/runner/MCPToolFcliPagedHelper.java | 30 ++------ .../runner/MCPToolFcliPagedHelperTest.java | 63 ----------------- .../cli/agent/mcp/unit/MCPJobManagerTest.java | 65 ++++++++++++++++- .../cli/cmd/RunBuildTimeFcliAction.java | 8 ++- .../action/runner/ActionFunctionExecutor.java | 2 +- .../action/runner/ActionRunnerVars.java | 2 +- .../cli/common/cli/util/FcliActionState.java | 31 ++++++++ .../common/cli/util/FcliExecutionContext.java | 70 +++++++++---------- .../cli/util/FcliExecutionContextHolder.java | 49 +++++++------ .../cli/util/FcliExecutionStrategy.java | 6 +- .../common/cli/util/FcliIsolationScope.java | 65 +++++++++++++++++ .../concurrent/job/AsyncJobManager.java | 42 ++++++----- ...bstractSessionDescriptorSupplierMixin.java | 2 +- .../cli/util/FcliExecutionContextTest.java | 53 ++++++++++---- .../concurrent/FcliConcurrencyTest.java | 4 +- .../AsyncJobManagerIsolationScopeTest.java | 59 ++++++++++++++++ ...actSessionDescriptorSupplierMixinTest.java | 6 +- .../helper/RPCJobEventListenerFactory.java | 15 ++-- .../helper/RPCMethodHandlerJobGetPage.java | 12 ++-- .../helper/RPCMethodHandlerJobGetStatus.java | 9 ++- .../helper/RPCMethodHandlerJobRemove.java | 9 ++- .../helper/RPCMethodHandlerRegistry.java | 28 +++++--- .../cli/util/rpc_server/helper/RPCServer.java | 11 ++- 28 files changed, 546 insertions(+), 250 deletions(-) delete mode 100644 fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelperTest.java create mode 100644 fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java create mode 100644 fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java create mode 100644 fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/job/AsyncJobManagerIsolationScopeTest.java diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index a50b1b5e423..f6156838084 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -26,6 +26,8 @@ import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfigLoader; import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpSessionDescriptorResolver; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.cli.util.FcliActionState; +import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.exception.FcliSimpleException; @@ -137,12 +139,9 @@ private T withRequestExecutionContext(McpTransportContext transportContext, MCPServerHttpSessionDescriptorResolver sessionDescriptorResolver, Supplier supplier) { - var executionContext = FcliExecutionContextHolder.pushNew(); + var isolationScope = sessionDescriptorResolver.getOrCreateIsolationScope(transportContext); + FcliExecutionContextHolder.push(new FcliExecutionContext(isolationScope, new FcliActionState())); try { - // HTTP MCP is stateless, so per-request auth/session data must be attached here - // for downstream session resolution and paged/background job isolation. - executionContext.setMcpRequestAuthScopeKey(sessionDescriptorResolver.getAuthScopeKey(transportContext)); - executionContext.setTransientSessionDescriptor(sessionDescriptorResolver.getOrCreateSessionDescriptor(transportContext)); return supplier.get(); } finally { FcliExecutionContextHolder.pop(); diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java index f41c543fb1c..8efb7e496dd 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.function.Supplier; import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; @@ -49,8 +50,11 @@ import com.fortify.cli.common.action.model.ActionMcpIncludeExclude; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.cli.util.FcliIsolationScope; import com.fortify.cli.common.cli.util.StdioHelper; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.concurrent.job.cli.mixin.AsyncJobManagerMixin; @@ -94,7 +98,8 @@ public class AgentMCPStartStdioCommand extends AbstractRunnableCommand { @Mixin private AsyncJobManagerMixin asyncJobManagerMixin; private static final AsyncJobManager.Config MCP_ASYNC_DEFAULTS = AsyncJobManager.Config.builder().build(); private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); - private final FcliExecutionContext sharedFunctionContext = new FcliExecutionContext(); + private final FcliIsolationScope sharedIsolationScope = new FcliIsolationScope(); + private final FcliExecutionContext sharedFunctionContext = new FcliExecutionContext(sharedIsolationScope, new FcliActionState()); private MCPJobManager jobManager; @Override @@ -178,6 +183,7 @@ private List createToolSpecs() { result.addAll(module.getSubcommandsStream() .filter(spec->!FcliCommandSpecHelper.isMcpIgnored(spec)) .map(this::createCommandToolSpec) + .map(this::wrapToolSpec) .peek(s->log.debug("Registering cmd tool: {}", s.tool().name())) .toList()); if ( module.hasActionCmd() ) { @@ -192,7 +198,7 @@ private List createToolSpecs() { } } // Job management tool - result.add(jobManager.getJobToolSpecification()); + result.add(wrapToolSpec(jobManager.getJobToolSpecification())); return result; } @@ -204,7 +210,7 @@ private List createResourceTemplateSpecs() { result.addAll(createImportedFunctionResourceTemplateSpecs(action)); } } - return result; + return result.stream().map(this::wrapResourceTemplateSpec).toList(); } private List createActionToolSpecs() { @@ -212,7 +218,7 @@ private List createActionToolSpecs() { var validationHandler = ActionValidationHandler.WARN; return ActionLoaderHelper.streamAsActions(actionSources, validationHandler) .filter(this::includeActionAsMcpTool) - .map(a->new ActionToolSpecHelper(module.toString(), a).createToolSpec()) + .map(a->wrapToolSpec(new ActionToolSpecHelper(module.toString(), a).createToolSpec())) .peek(s->log.debug("Registering action tool: {}", s.tool().name())) .toList(); } @@ -230,6 +236,27 @@ private SyncToolSpecification createCommandToolSpec(CommandSpec spec) { return new CommandToolSpecHelper(spec).createToolSpec(); } + private SyncToolSpecification wrapToolSpec(SyncToolSpecification specification) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(specification.tool()) + .callHandler((ctx, request) -> withSharedExecutionContext(() -> specification.callHandler().apply(ctx, request))) + .build(); + } + + private SyncResourceTemplateSpecification wrapResourceTemplateSpec(SyncResourceTemplateSpecification specification) { + return new SyncResourceTemplateSpecification(specification.resourceTemplate(), + (ctx, request) -> withSharedExecutionContext(() -> specification.readHandler().apply(ctx, request))); + } + + private T withSharedExecutionContext(Supplier supplier) { + FcliExecutionContextHolder.push(new FcliExecutionContext(sharedIsolationScope, new FcliActionState())); + try { + return supplier.get(); + } finally { + FcliExecutionContextHolder.pop(); + } + } + private Action loadImportedAction(String importFile) { var sources = ActionSource.externalActionSources(importFile); var validationHandler = ActionValidationHandler.WARN; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java index 0d1308cb639..d3fa2f023de 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java @@ -32,6 +32,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.agent.mcp.helper.runner.MCPToolAsyncJobManager; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import io.modelcontextprotocol.server.McpServerFeatures; @@ -61,10 +62,13 @@ public class MCPJobManager { private final ScheduledExecutorService progressExecutor; private final long safeReturnMillis; private final long progressIntervalMillis; - private final Map jobs = new ConcurrentHashMap<>(); private final ObjectMapper mapper = new ObjectMapper(); private final MCPToolAsyncJobManager asyncJobManager; + private static final class ScopeState { + private final Map jobs = new ConcurrentHashMap<>(); + } + public MCPJobManager(int workThreads, int progressThreads, long safeReturnMillis, long progressIntervalMillis, AsyncJobManager asyncJobManager) { this.workExecutor = Executors.newFixedThreadPool(workThreads); this.progressExecutor = Executors.newScheduledThreadPool(progressThreads); @@ -95,13 +99,23 @@ public CallToolResult execute(McpSyncServerExchange exchange, String toolName, C private JobExecution createAndQueueJob(String toolName, ProgressStrategy progressStrategy) { String token = UUID.randomUUID().toString(); JobExecution exec = new JobExecution(token, toolName, progressStrategy); - jobs.put(token, exec); + getScopeState().jobs.put(token, exec); log.info("Queued job {} for tool {}", token, toolName); return exec; } private CompletableFuture startJobExecution(McpSyncServerExchange exchange, JobExecution exec, Callable work, boolean sendNotifications) { - CompletableFuture future = CompletableFuture.supplyAsync(() -> executeWork(exchange, exec, work, sendNotifications), workExecutor) + // Capture the calling thread's execution context so that the worker thread inherits + // the same isolation scope (auth scope key, transient sessions) when executing the work. + var parentContext = FcliExecutionContextHolder.current(); + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + FcliExecutionContextHolder.push(parentContext.createChild()); + try { + return executeWork(exchange, exec, work, sendNotifications); + } finally { + FcliExecutionContextHolder.pop(); + } + }, workExecutor) .whenComplete((res, t) -> handleJobCompletion(exchange, exec, res, t, sendNotifications)); exec.future = future; return future; @@ -170,7 +184,7 @@ private CallToolResult waitForCompletionOrReturnInProgress(JobExecution exec, Co while ( System.currentTimeMillis()-start < safeReturnMillis ) { if ( future.isDone() ) { exec.cleanup(); - jobs.remove(exec.token); + getScopeState().jobs.remove(exec.token); return exec.result!=null?exec.result:buildErrorResult(exec.token, exec.toolName, "No result"); } sleep(100); @@ -208,7 +222,7 @@ public String trackFuture(String toolName, CompletableFuture future, Progress private JobExecution createRunningJob(String toolName, ProgressStrategy progressStrategy) { String token = UUID.randomUUID().toString(); JobExecution exec = new JobExecution(token, toolName, progressStrategy); - jobs.put(token, exec); + getScopeState().jobs.put(token, exec); exec.status = JobStatus.RUNNING; exec.startTime = Instant.now(); log.info("Tracking external future as job {} tool {}", token, toolName); @@ -291,7 +305,7 @@ private CallToolResult handleJobOperation(String token, String op) { if ( token==null || op==null ) { return CallToolResult.builder().addTextContent("Missing job_token or operation").isError(true).build(); } - JobExecution exec = jobs.get(token); + JobExecution exec = getScopeState().jobs.get(token); try { return JobOperation.valueOf(op.toUpperCase()).apply(this, exec, token); } catch ( IllegalArgumentException e ) { @@ -333,7 +347,7 @@ private CallToolResult wait(JobExecution exec, String token) { } if ( exec.future!=null && exec.future.isDone() ) { exec.cleanup(); - jobs.remove(token); + getScopeState().jobs.remove(token); } return status(exec, token); } @@ -516,6 +530,10 @@ private String buildStatusMessage(JobExecution exec, boolean finalNotification, return root.toPrettyString(); } + private ScopeState getScopeState() { + return FcliExecutionContextHolder.current().getIsolationScope().getOrCreateScopedState(ScopeState.class, ScopeState::new); + } + private static enum JobOperation { STATUS { @Override diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 515b07a9333..be93353fc88 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -26,7 +26,9 @@ import org.apache.commons.lang3.StringUtils; +import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliIsolationScope; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.rest.unirest.config.UrlConfig; import com.fortify.cli.common.session.helper.ISessionDescriptor; @@ -71,14 +73,21 @@ protected boolean removeEldestEntry(Map.Entry eldest return size() > MAX_SESSION_DESCRIPTOR_CACHE_SIZE; } }; - private final Map functionContextCache = new LinkedHashMap<>(16, 0.75f, true) { + private final Map isolationScopeCache = new LinkedHashMap<>(16, 0.75f, true) { private static final long serialVersionUID = 1L; @Override - protected boolean removeEldestEntry(Map.Entry eldest) { + protected boolean removeEldestEntry(Map.Entry eldest) { return size() > MAX_SESSION_DESCRIPTOR_CACHE_SIZE; } }; + private static final class FunctionContextState { + private final FcliExecutionContext context; + + private FunctionContextState(FcliIsolationScope isolationScope) { + this.context = new FcliExecutionContext(isolationScope, new FcliActionState()); + } + } public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext transportContext) { var cacheKey = createAuthCacheKey(transportContext); @@ -93,8 +102,15 @@ public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext trans * {@code global.*} action variables are not shared across different callers. */ public FcliExecutionContext getOrCreateFunctionContext(String authScopeKey) { - synchronized (functionContextCache) { - return functionContextCache.computeIfAbsent(authScopeKey, ignored -> new FcliExecutionContext()); + var isolationScope = getOrCreateIsolationScope(authScopeKey); + return isolationScope.getOrCreateScopedState(FunctionContextState.class, + () -> new FunctionContextState(isolationScope)).context; + } + + public FcliIsolationScope getOrCreateIsolationScope(McpTransportContext transportContext) { + var authScopeKey = createAuthCacheKey(transportContext); + synchronized (isolationScopeCache) { + return isolationScopeCache.computeIfAbsent(authScopeKey, ignored -> createIsolationScope(authScopeKey, transportContext)); } } @@ -102,6 +118,23 @@ public String getAuthScopeKey(McpTransportContext transportContext) { return createAuthCacheKey(transportContext); } + private FcliIsolationScope getOrCreateIsolationScope(String authScopeKey) { + synchronized (isolationScopeCache) { + var result = isolationScopeCache.get(authScopeKey); + if ( result == null ) { + throw new IllegalStateException("No isolation scope found for auth scope key"); + } + return result; + } + } + + private FcliIsolationScope createIsolationScope(String authScopeKey, McpTransportContext transportContext) { + var result = new FcliIsolationScope(); + result.setMcpRequestAuthScopeKey(authScopeKey); + result.setTransientSessionDescriptor(getOrCreateSessionDescriptor(transportContext)); + return result; + } + String createAuthCacheKey(McpTransportContext transportContext) { var auth = parseAuthHeader(transportContext); return switch (auth.product()) { diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java index 9f93f1a869f..546850fc969 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java @@ -34,10 +34,13 @@ * or {@link #getOrStartBackground(String, boolean, IAsyncTask)}. */ public class MCPToolAsyncJobManager { + private static final class ScopeState { + private final CachingJobEventListener cachingListener = new CachingJobEventListener(); + private final Map jobTokens = new ConcurrentHashMap<>(); + } + private final AsyncJobManager delegate; private final MCPJobManager jobManager; - private final CachingJobEventListener cachingListener = new CachingJobEventListener(); - private final Map jobTokens = new ConcurrentHashMap<>(); public MCPToolAsyncJobManager(MCPJobManager jobManager, AsyncJobManager delegate) { this.jobManager = jobManager; @@ -48,10 +51,11 @@ public MCPToolAsyncJobManager(MCPJobManager jobManager, AsyncJobManager delegate * Return completed result if present and valid, or null. */ public MCPToolResult getCached(String jobId) { - if (!cachingListener.isComplete(jobId)) { + var scopeState = getScopeState(); + if (!scopeState.cachingListener.isComplete(jobId)) { return null; } - var page = cachingListener.getPage(jobId, 0, Integer.MAX_VALUE); + var page = scopeState.cachingListener.getPage(jobId, 0, Integer.MAX_VALUE); var builder = MCPToolResult.builder() .exitCode(page.getExitCode()) .stderr(page.getStderr()) @@ -64,7 +68,7 @@ public MCPToolResult getCached(String jobId) { } public String getJobToken(String jobId) { - return jobTokens.get(jobId); + return getScopeState().jobTokens.get(jobId); } /** @@ -73,32 +77,30 @@ public String getJobToken(String jobId) { * background job is in progress or was just started. */ public InProgressEntry getOrStartBackground(String jobId, boolean refresh, IAsyncTask task) { - if (!refresh && cachingListener.isComplete(jobId)) { + var scopeState = getScopeState(); + if (!refresh && scopeState.cachingListener.isComplete(jobId)) { return null; } if (refresh) { - cachingListener.remove(jobId); - jobTokens.remove(jobId); + scopeState.cachingListener.remove(jobId); + scopeState.jobTokens.remove(jobId); } if (delegate.isRunning(jobId)) { - return new InProgressEntry(jobId, cachingListener, jobTokens.get(jobId)); + return new InProgressEntry(jobId, scopeState.cachingListener, scopeState.jobTokens.get(jobId)); } - // Start new background job with the semantic jobId - var transientSessionDescriptors = Map.copyOf(FcliExecutionContextHolder.current().getTransientSessionDescriptors()); delegate.startBackground(AsyncJobManager.TaskDescriptor.builder() .jobId(jobId) .task(task) - .listener(cachingListener) + .listener(scopeState.cachingListener) .description("mcp:" + jobId) - .executionContextConfigurer(ctx -> transientSessionDescriptors.values().forEach(ctx::setTransientSessionDescriptor)) .build()); var future = delegate.getFuture(jobId); if (future != null) { var jobToken = jobManager.trackFuture("async_job", future, - () -> cachingListener.getLoadedCount(jobId)); - jobTokens.put(jobId, jobToken); + () -> scopeState.cachingListener.getLoadedCount(jobId)); + scopeState.jobTokens.put(jobId, jobToken); } - return new InProgressEntry(jobId, cachingListener, jobTokens.get(jobId)); + return new InProgressEntry(jobId, scopeState.cachingListener, scopeState.jobTokens.get(jobId)); } /** Cancel a background async job if running. */ @@ -111,6 +113,10 @@ public void shutdown() { delegate.shutdown(); } + private ScopeState getScopeState() { + return FcliExecutionContextHolder.current().getIsolationScope().getOrCreateScopedState(ScopeState.class, ScopeState::new); + } + /** Thin wrapper giving access to background collection state via CachingJobEventListener. */ public static final class InProgressEntry { private final String jobId; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java index ba0e83ae675..3e8c1a8a8d2 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java @@ -17,7 +17,6 @@ import com.fortify.cli.agent.mcp.helper.MCPJobManager; import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; -import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -29,10 +28,9 @@ * Callers supply a {@link BackgroundStarter} that encapsulates how to start or resume * background record collection; this class owns the cache-check, wait, and result assembly. *

- * In HTTP mode, paged/background execution must be isolated per request auth context. - * This helper enforces that boundary by scoping semantic job IDs with the hashed auth - * scope stored in the current {@link FcliExecutionContextHolder} context before any cache - * or background-job lookup occurs. + * Paged/background execution is isolated through the current execution context's + * shared isolation scope. Callers use semantic job IDs directly; cache and job + * registry lookups are already partitioned by the active scope. * * @author Ruud Senden */ @@ -70,38 +68,24 @@ static PageParams from(CallToolRequest request) { * resuming background collection via the supplied {@link BackgroundStarter}. */ CallToolResult run(String jobId, PageParams pageParams, BackgroundStarter starter) { - var scopedJobId = scopeJobId(jobId); try { - return tryGetCachedResult(scopedJobId, pageParams) + return tryGetCachedResult(jobId, pageParams) .or(() -> { try { - return tryGetInProgressResult(scopedJobId, pageParams, starter); + return tryGetInProgressResult(jobId, pageParams, starter); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("Interrupted while waiting for records", e); } }) - .orElseThrow(() -> new IllegalStateException("No result path succeeded for: " + scopedJobId)); + .orElseThrow(() -> new IllegalStateException("No result path succeeded for: " + jobId)); } catch (Exception e) { log.warn("Paged helper failed jobId='{}' offset={} limit={} error={}", - scopedJobId, pageParams.offset, pageParams.limit, e.toString()); + jobId, pageParams.offset, pageParams.limit, e.toString()); return MCPToolResult.fromError(e).asCallToolResult(); } } - /** - * Scope a semantic job ID by the current request auth context when present. - * - * In stateless HTTP MCP mode, two clients may invoke the same tool with identical - * arguments but different credentials. Using an auth-scoped key prevents those - * requests from sharing cached pages or background job state. Callers that interact - * with paged/background async state must use the scoped key consistently. - */ - static String scopeJobId(String jobId) { - var authScopeKey = FcliExecutionContextHolder.getMcpRequestAuthScopeKey(); - return authScopeKey == null ? jobId : authScopeKey + "|" + jobId; - } - private Optional tryGetCachedResult(String jobId, PageParams params) { if (params.refresh) { return Optional.empty(); diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelperTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelperTest.java deleted file mode 100644 index 458bc53330e..00000000000 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelperTest.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.agent.mcp.helper.runner; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; - -import org.junit.jupiter.api.Test; - -import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; - -class MCPToolFcliPagedHelperTest { - @Test - void scopeJobIdPrefixesCurrentAuthScopeKey() { - FcliExecutionContextHolder.pushNew(); - try { - FcliExecutionContextHolder.current().setMcpRequestAuthScopeKey("ssc|hashed-auth"); - - var scopedJobId = MCPToolFcliPagedHelper.scopeJobId("fcli_fn_sscAppListStream:{\"name\":\"demo\"}"); - - assertEquals("ssc|hashed-auth|fcli_fn_sscAppListStream:{\"name\":\"demo\"}", scopedJobId); - } finally { - FcliExecutionContextHolder.pop(); - } - } - - @Test - void scopeJobIdDiffersAcrossAuthScopesForSameSemanticJobId() { - var semanticJobId = "fcli_fn_sscAppListStream:{\"name\":\"demo\"}"; - String sscScopedJobId; - String fodScopedJobId; - - FcliExecutionContextHolder.pushNew(); - try { - FcliExecutionContextHolder.current().setMcpRequestAuthScopeKey("ssc|hashed-auth-1"); - sscScopedJobId = MCPToolFcliPagedHelper.scopeJobId(semanticJobId); - } finally { - FcliExecutionContextHolder.pop(); - } - - FcliExecutionContextHolder.pushNew(); - try { - FcliExecutionContextHolder.current().setMcpRequestAuthScopeKey("ssc|hashed-auth-2"); - fodScopedJobId = MCPToolFcliPagedHelper.scopeJobId(semanticJobId); - } finally { - FcliExecutionContextHolder.pop(); - } - - assertNotEquals(sscScopedJobId, fodScopedJobId); - assertEquals("ssc|hashed-auth-1|" + semanticJobId, sscScopedJobId); - assertEquals("ssc|hashed-auth-2|" + semanticJobId, fodScopedJobId); - } -} \ No newline at end of file diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java index 7d0e07a0363..1a7c63f2acf 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java @@ -22,11 +22,18 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.common.cli.util.FcliActionState; +import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.cli.util.FcliIsolationScope; import com.fortify.cli.common.concurrent.job.AsyncJobManager; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; /** * Unit tests for {@link MCPJobManager}. Tests basic job lifecycle management, @@ -44,11 +51,13 @@ void setUp() { // Create job manager with short timeout for testing jobManager = new MCPJobManager(4, 2, 500, 100, new AsyncJobManager()); objectMapper = new ObjectMapper(); + // Tests run outside of a server request, so push a fresh root context. + FcliExecutionContextHolder.pushNew(); } @AfterEach void tearDown() { - // Job manager doesn't need explicit shutdown for these tests + FcliExecutionContextHolder.pop(); } @Test @@ -231,4 +240,58 @@ void shouldUseTickingProgressStrategy() throws Exception { // Ticking strategy should have incremented the counter during progress updates // Note: The exact count depends on timing, but should be > 0 } + + @Test + void jobToolShouldOnlySeeJobsFromCurrentIsolationScope() throws Exception { + var scopeOne = new FcliIsolationScope(); + var scopeTwo = new FcliIsolationScope(); + var jobToken = withIsolationScope(scopeOne, + () -> jobManager.trackFuture("scoped_test_tool", + CompletableFuture.completedFuture("done"), + MCPJobManager.recordCounter(new AtomicInteger()))); + + var scopeTwoStatus = withIsolationScope(scopeTwo, () -> getJobStatus(jobToken)); + assertEquals("not_found", scopeTwoStatus.path("status").asText()); + assertEquals(jobToken, scopeTwoStatus.path("job_token").asText()); + + var scopeOneStatus = withIsolationScope(scopeOne, () -> getJobStatus(jobToken)); + assertNotEquals("not_found", scopeOneStatus.path("status").asText()); + assertEquals(jobToken, scopeOneStatus.path("job_token").asText()); + assertEquals("scoped_test_tool", scopeOneStatus.path("tool").asText()); + } + + private JsonNode getJobStatus(String jobToken) { + var request = new CallToolRequest(MCPJobManager.JOB_TOOL_NAME, + java.util.Map.of("operation", "status", "job_token", jobToken)); + var result = jobManager.getJobToolSpecification().callHandler().apply(null, request); + return parseToolResult(result); + } + + private JsonNode parseToolResult(CallToolResult result) { + var text = result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElseThrow(); + try { + return objectMapper.readTree(text); + } catch (Exception e) { + throw new RuntimeException("Error parsing MCP job tool result", e); + } + } + + private T withIsolationScope(FcliIsolationScope isolationScope, ThrowingSupplier supplier) throws Exception { + FcliExecutionContextHolder.push(new FcliExecutionContext(isolationScope, new FcliActionState())); + try { + return supplier.get(); + } finally { + FcliExecutionContextHolder.pop(); + } + } + + @FunctionalInterface + private interface ThrowingSupplier { + T get() throws Exception; + } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java index e4c0332bafa..7cec81e8b37 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java @@ -24,6 +24,7 @@ import com.fortify.cli.common.action.runner.ActionRunner; import com.fortify.cli.common.action.runner.ActionRunnerConfig; import com.fortify.cli.common.action.runner.processor.ActionCliOptionsProcessor.ActionOptionHelper; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.cli.util.SimpleOptionsParser.OptionsParseResult; import com.fortify.cli.common.progress.helper.ProgressWriterI18n; import com.fortify.cli.common.progress.helper.ProgressWriterType; @@ -60,7 +61,12 @@ public static void main(String[] args) { .progressWriter(progressWriter) .onValidationErrors(RunBuildTimeFcliAction::onValidationErrors) .build(); - new ActionRunner(config).run(actionArgs); + FcliExecutionContextHolder.pushNew(); + try { + new ActionRunner(config).run(actionArgs); + } finally { + FcliExecutionContextHolder.pop(); + } } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java index ddad6ea49e8..977a219a3e7 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java @@ -77,7 +77,7 @@ public ActionFunction getFunction() { * For streaming functions: an IActionStepForEachProcessor. */ public Object execute(ObjectNode argsNode) { - FcliExecutionContextHolder.push(contextSupplier.get()); + FcliExecutionContextHolder.push(contextSupplier.get().createChildWithSharedActionState()); try { var config = ActionRunnerConfig.builder() .action(action) diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java index 0c03a8f5c4f..34531a4c536 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java @@ -67,7 +67,7 @@ public final class ActionRunnerVars { public ActionRunnerVars(IConfigurableSpelEvaluator spelEvaluator, ObjectNode cliOptions) { this.spelEvaluator = spelEvaluator; this.values = objectMapper.createObjectNode(); - this.globalActionValues = FcliExecutionContextHolder.current().getGlobalActionValues(); + this.globalActionValues = FcliExecutionContextHolder.current().getActionState().getGlobalActionValues(); this.values.set(GLOBAL_VAR_NAME, this.globalActionValues); this.values.set(CLI_OPTIONS_VAR_NAME, cliOptions); this.parent = null; diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java new file mode 100644 index 00000000000..952e7c9d517 --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.Getter; + +/** + * Mutable state shared by related action or function invocations. + * + *

This state intentionally lives outside {@link FcliExecutionContext} so that + * callers can share {@code global.*} values across imported function invocations + * while still creating a fresh execution frame for each invocation.

+ */ +public final class FcliActionState { + @Getter private final ObjectNode globalActionValues = new ObjectNode(JsonNodeFactory.instance, new ConcurrentHashMap<>()); +} diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java index b2915ef5bfa..a1464e19e87 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java @@ -18,69 +18,69 @@ import java.nio.file.Path; import java.security.SecureRandom; import java.util.Base64; -import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.crypto.helper.EncryptionHelper; import com.fortify.cli.common.rest.unirest.UnirestContext; -import com.fortify.cli.common.session.helper.ISessionDescriptor; import lombok.Getter; /** - * Per-top-level execution context holding mutable execution-scoped state. - * The {@code globalActionValues} ObjectNode is backed by a {@link ConcurrentHashMap} - * to allow safe concurrent access from multiple threads (e.g. async jobs, - * server request handlers). + * Execution-frame local state for a single invocation. + * + *

Each execution context owns resources that must not be shared across + * independent invocations, such as the per-execution {@link UnirestContext} and + * ephemeral encryption state. Longer-lived isolation concerns like request/auth + * scoped caches and transient session descriptors are kept in the associated + * {@link FcliIsolationScope}, while shared action variables live in the + * associated {@link FcliActionState}.

*/ public final class FcliExecutionContext { - @Getter private final ObjectNode globalActionValues = new ObjectNode(JsonNodeFactory.instance, new ConcurrentHashMap<>()); + @Getter private final FcliIsolationScope isolationScope; + @Getter private final FcliActionState actionState; @Getter private final UnirestContext unirestContext = new UnirestContext(); - @Getter private volatile String mcpRequestAuthScopeKey; // Encryption helper used for encrypt/decrypt in this execution. Default to global DEFAULT. private volatile EncryptionHelper encryptionHelper = EncryptionHelper.DEFAULT; // Set of absolute file paths that were saved using ephemeral encryption during this execution - private final Set ephemeralEncryptedFiles = ConcurrentHashMap.newKeySet(); - @Getter private final Map transientSessionDescriptors = new ConcurrentHashMap<>(); + private final Set ephemeralEncryptedFiles = java.util.concurrent.ConcurrentHashMap.newKeySet(); - public void clearTransientSessionDescriptors() { - transientSessionDescriptors.clear(); + public FcliExecutionContext() { + this(new FcliIsolationScope(), new FcliActionState()); } - public ISessionDescriptor getTransientSessionDescriptor(String type) { - return type == null ? null : transientSessionDescriptors.get(type); + public FcliExecutionContext(FcliIsolationScope isolationScope, FcliActionState actionState) { + this.isolationScope = isolationScope == null ? new FcliIsolationScope() : isolationScope; + this.actionState = actionState == null ? new FcliActionState() : actionState; } - public void setTransientSessionDescriptor(ISessionDescriptor descriptor) { - if ( descriptor == null ) { - return; - } - transientSessionDescriptors.put(descriptor.getType(), descriptor); - } - - public void clearTransientSessionDescriptor(String type) { - if ( type != null ) { - transientSessionDescriptors.remove(type); - } + /** + * Create a child execution frame that inherits the current isolation scope + * while starting with a fresh action state. + */ + public FcliExecutionContext createChild() { + return new FcliExecutionContext(isolationScope, new FcliActionState()); } - public void setMcpRequestAuthScopeKey(String mcpRequestAuthScopeKey) { - this.mcpRequestAuthScopeKey = mcpRequestAuthScopeKey; + /** + * Create a child execution frame that inherits both the isolation scope and + * shared action state. + */ + public FcliExecutionContext createChildWithSharedActionState() { + return new FcliExecutionContext(isolationScope, actionState); } public String info() { - return String.format("FcliExecutionContext@%s(%d) actionGlobalValues@%s(%d) unirestContext@%s(%s) transientSessions=%d authScope=%s", + return String.format("FcliExecutionContext@%s(%d) isolationScope@%s actionState@%s actionGlobalValues@%s(%d) unirestContext@%s(%s) transientSessions=%d authScope=%s", Integer.toHexString(System.identityHashCode(this)), FcliExecutionContextHolder.stackDepth(), - Integer.toHexString(System.identityHashCode(globalActionValues)), - globalActionValues.size(), + Integer.toHexString(System.identityHashCode(isolationScope)), + Integer.toHexString(System.identityHashCode(actionState)), + Integer.toHexString(System.identityHashCode(actionState.getGlobalActionValues())), + actionState.getGlobalActionValues().size(), Integer.toHexString(System.identityHashCode(unirestContext)), unirestContext.getCachedInstanceCount(), - transientSessionDescriptors.size(), - mcpRequestAuthScopeKey != null ? "set" : "unset"); + isolationScope.getTransientSessionDescriptors().size(), + isolationScope.getMcpRequestAuthScopeKey() != null ? "set" : "unset"); } /** diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java index ad0528952b1..dbdd878d329 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java @@ -21,6 +21,11 @@ * Explicit holder for the current thread's execution context stack. * Use push()/pop() to manage nested execution contexts. No implicit * inheritance to child threads is performed; propagation must be explicit. + * + *

A context must always be pushed explicitly before any code that calls + * {@link #current()} — typically at the entry point of each execution path + * (plain CLI via {@link FcliExecutionStrategy}, MCP request handlers, RPC + * request dispatch). Callers should never rely on automatic context creation.

*/ public final class FcliExecutionContextHolder { private static final ThreadLocal> HOLDER = ThreadLocal.withInitial(ArrayDeque::new); @@ -30,10 +35,16 @@ private FcliExecutionContextHolder() {} /** Push the given context onto the current thread's context stack. */ public static void push(FcliExecutionContext ctx) { HOLDER.get().push(ctx); } - /** Push a fresh, empty context and return it. */ + /** + * Push a fresh execution frame, inheriting the current isolation scope when + * a parent context is present so nested invocations remain within the same + * isolation boundary while still receiving a fresh action state. + */ public static FcliExecutionContext pushNew() { - HOLDER.get().push(new FcliExecutionContext()); - return HOLDER.get().peek(); + var stack = HOLDER.get(); + var context = stack.isEmpty() ? new FcliExecutionContext() : stack.peek().createChild(); + stack.push(context); + return context; } /** Pop the current context and return it; returns null if none present. */ @@ -46,13 +57,19 @@ public static FcliExecutionContext pop() { } /** - * Return the current (top) context. If none is present a default - * top-level context is created and pushed so this method never returns - * null and callers may safely assume a non-null result. + * Return the current (top) context. + * + * @throws IllegalStateException if no context has been pushed on the current thread, + * which indicates a missing push at an execution entry point. */ public static FcliExecutionContext current() { - var stack = HOLDER.get(); - if ( stack.isEmpty() ) { stack.push(new FcliExecutionContext()); } + var stack = HOLDER.get(); + if ( stack.isEmpty() ) { + throw new IllegalStateException( + "No FcliExecutionContext on the current thread. " + + "Ensure a context is pushed at every execution entry point " + + "(CLI command, MCP request, RPC request)."); + } return stack.peek(); } @@ -61,23 +78,11 @@ public static FcliExecutionContext current() { * through the current thread's execution-context stack. */ public static ISessionDescriptor getTransientSessionDescriptor(String type) { - for ( var context : HOLDER.get() ) { - var descriptor = context.getTransientSessionDescriptor(type); - if ( descriptor != null ) { - return descriptor; - } - } - return null; + return current().getIsolationScope().getTransientSessionDescriptor(type); } public static String getMcpRequestAuthScopeKey() { - for ( var context : HOLDER.get() ) { - var authScopeKey = context.getMcpRequestAuthScopeKey(); - if ( authScopeKey != null ) { - return authScopeKey; - } - } - return null; + return current().getIsolationScope().getMcpRequestAuthScopeKey(); } /** Return the current stack depth. Useful for logging/troubleshooting. */ diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java index 7091faa8958..e184c7e77a2 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java @@ -54,13 +54,17 @@ public FcliExecutionStrategy(IExecutionStrategy delegate) { public int execute(ParseResult parseResult) throws CommandLine.ExecutionException { var leaf = getLeafParseResult(parseResult); var leafSpec = leaf.commandSpec(); - var execCtx = FcliExecutionContextHolder.current(); + // Push a context for this command execution. pushNew() inherits the current + // isolation scope when called from inside a server request (MCP/RPC), or + // creates a fresh root context for plain CLI invocations. + var execCtx = FcliExecutionContextHolder.pushNew(); try { log.debug("Starting command execution; execInfo={} command={}", execCtx.info(), leafSpec.qualifiedName()); initializeCommand(leafSpec); return delegate.execute(parseResult); } finally { log.debug("Finished command execution; execInfo={} command={}", execCtx.info(), leafSpec.qualifiedName()); + FcliExecutionContextHolder.pop(); } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java new file mode 100644 index 00000000000..9cee234c3e9 --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java @@ -0,0 +1,65 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import com.fortify.cli.common.session.helper.ISessionDescriptor; + +import lombok.Getter; + +/** + * Shared isolation boundary for request, auth, session, and cache scoped state. + * + *

Execution frames represented by {@link FcliExecutionContext} may be created + * and discarded frequently, but related invocations can still share the same + * isolation scope. This allows nested command invocations and background jobs to + * resolve the same request/auth scoped caches and transient session descriptors + * without reusing the same execution frame.

+ */ +public final class FcliIsolationScope { + @Getter private volatile String mcpRequestAuthScopeKey; + @Getter private final Map transientSessionDescriptors = new ConcurrentHashMap<>(); + private final Map, Object> scopedStates = new ConcurrentHashMap<>(); + + public ISessionDescriptor getTransientSessionDescriptor(String type) { + return type == null ? null : transientSessionDescriptors.get(type); + } + + public void setTransientSessionDescriptor(ISessionDescriptor descriptor) { + if ( descriptor != null ) { + transientSessionDescriptors.put(descriptor.getType(), descriptor); + } + } + + public void clearTransientSessionDescriptor(String type) { + if ( type != null ) { + transientSessionDescriptors.remove(type); + } + } + + public void clearTransientSessionDescriptors() { + transientSessionDescriptors.clear(); + } + + public void setMcpRequestAuthScopeKey(String mcpRequestAuthScopeKey) { + this.mcpRequestAuthScopeKey = mcpRequestAuthScopeKey; + } + + @SuppressWarnings("unchecked") + public T getOrCreateScopedState(Class type, Supplier supplier) { + return (T)scopedStates.computeIfAbsent(type, ignored -> supplier.get()); + } +} diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java index f99fa21d642..2534ca1a273 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java @@ -20,7 +20,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import java.util.function.Supplier; import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; @@ -60,10 +60,13 @@ public static class TaskDescriptor { IAsyncTask task; @Builder.Default IJobEventListener listener = IJobEventListener.NOOP; @Builder.Default String description = ""; - Consumer executionContextConfigurer; + Supplier executionContextSupplier; + } + + private static final class ScopeState { + private final Map jobs = new ConcurrentHashMap<>(); } - private final Map jobs = new ConcurrentHashMap<>(); private final ExecutorService backgroundExecutor; public AsyncJobManager() { @@ -93,18 +96,17 @@ public String startBackground(TaskDescriptor descriptor) { var jobId = descriptor.getJobId() == null ? UUID.randomUUID().toString() : descriptor.getJobId(); var listener = descriptor.getListener(); var description = descriptor.getDescription() == null ? "" : descriptor.getDescription(); - var executionContextConfigurer = descriptor.getExecutionContextConfigurer(); + var parentContext = FcliExecutionContextHolder.current(); + var executionContextSupplier = descriptor.getExecutionContextSupplier(); + var scopeState = getScopeState(); var entry = new JobEntry(jobId, description); - jobs.put(jobId, entry); + scopeState.jobs.put(jobId, entry); listener.onJobStarted(jobId, description); var future = CompletableFuture.runAsync(() -> { entry.thread = Thread.currentThread(); - var context = FcliExecutionContextHolder.pushNew(); - if ( executionContextConfigurer != null ) { - executionContextConfigurer.accept(context); - } + FcliExecutionContextHolder.push(executionContextSupplier != null ? executionContextSupplier.get() : parentContext.createChild()); // Register per-thread progress callback so that progress writer // messages are forwarded to the job event listener as notifications. // Masking is applied by StdioHelper before invoking the callback. @@ -148,7 +150,7 @@ public String startBackground(TaskDescriptor descriptor) { * Cancel a running job. Returns {@code true} if the job was found and cancelled. */ public boolean cancel(String jobId) { - var entry = jobs.get(jobId); + var entry = getScopeState().jobs.get(jobId); if (entry != null && !entry.completed) { var thread = entry.thread; if (thread != null) { @@ -157,7 +159,7 @@ public boolean cancel(String jobId) { if (entry.future != null) { entry.future.cancel(true); } - jobs.remove(jobId); + getScopeState().jobs.remove(jobId); log.debug("Cancelled async job: jobId={}", jobId); return true; } @@ -166,26 +168,26 @@ public boolean cancel(String jobId) { /** Whether a job is currently running (not completed). */ public boolean isRunning(String jobId) { - var entry = jobs.get(jobId); + var entry = getScopeState().jobs.get(jobId); return entry != null && !entry.completed; } /** Return the future for a job, or null if not found. */ public java.util.concurrent.CompletableFuture getFuture(String jobId) { - var entry = jobs.get(jobId); + var entry = getScopeState().jobs.get(jobId); return entry != null ? entry.future : null; } /** Return info about all tracked jobs. */ public List listJobs() { - return jobs.values().stream() + return getScopeState().jobs.values().stream() .map(e -> new JobInfo(e.jobId, e.description, e.completed, e.exitCode, e.created)) .toList(); } /** Return info about a single tracked job, or null if not found. */ public JobInfo getJobInfo(String jobId) { - var entry = jobs.get(jobId); + var entry = getScopeState().jobs.get(jobId); return entry != null ? new JobInfo(entry.jobId, entry.description, entry.completed, entry.exitCode, entry.created) : null; @@ -193,18 +195,22 @@ public JobInfo getJobInfo(String jobId) { /** Remove a completed job from tracking. */ public void removeJob(String jobId) { - var entry = jobs.get(jobId); + var entry = getScopeState().jobs.get(jobId); if (entry != null && entry.completed) { - jobs.remove(jobId); + getScopeState().jobs.remove(jobId); } } /** Return the stdout captured for a completed job, or null. */ public String getStdout(String jobId) { - var entry = jobs.get(jobId); + var entry = getScopeState().jobs.get(jobId); return entry != null ? entry.stdout : null; } + private ScopeState getScopeState() { + return FcliExecutionContextHolder.current().getIsolationScope().getOrCreateScopedState(ScopeState.class, ScopeState::new); + } + /** * Shut down the background executor, waiting briefly for running jobs to finish. */ diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java index b38389439f9..c0f55e3f9dd 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java @@ -29,7 +29,7 @@ public final String getSessionName() { @SuppressWarnings("unchecked") private D getTransientSessionDescriptor() { - return (D)FcliExecutionContextHolder.getTransientSessionDescriptor(getSessionDescriptorType()); + return (D)FcliExecutionContextHolder.current().getIsolationScope().getTransientSessionDescriptor(getSessionDescriptorType()); } public abstract ISessionNameSupplier getSessionNameSupplier(); diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java index 473f699e228..941fab392a6 100644 --- a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Date; @@ -31,25 +32,25 @@ void transientSessionDescriptorsCanBeStoredByTypeAndCleared() { var sscDescriptor = new DummySessionDescriptor("SSC"); var fodDescriptor = new DummySessionDescriptor("FoD"); - assertTrue(context.getTransientSessionDescriptors().isEmpty()); - assertNull(context.getTransientSessionDescriptor("SSC")); + assertTrue(context.getIsolationScope().getTransientSessionDescriptors().isEmpty()); + assertNull(context.getIsolationScope().getTransientSessionDescriptor("SSC")); assertFalse(context.info().contains("transientSessions=1")); - context.setTransientSessionDescriptor(sscDescriptor); - context.setTransientSessionDescriptor(fodDescriptor); + context.getIsolationScope().setTransientSessionDescriptor(sscDescriptor); + context.getIsolationScope().setTransientSessionDescriptor(fodDescriptor); - assertSame(sscDescriptor, context.getTransientSessionDescriptor("SSC")); - assertSame(fodDescriptor, context.getTransientSessionDescriptor("FoD")); + assertSame(sscDescriptor, context.getIsolationScope().getTransientSessionDescriptor("SSC")); + assertSame(fodDescriptor, context.getIsolationScope().getTransientSessionDescriptor("FoD")); assertTrue(context.info().contains("transientSessions=2")); - context.clearTransientSessionDescriptor("SSC"); + context.getIsolationScope().clearTransientSessionDescriptor("SSC"); - assertNull(context.getTransientSessionDescriptor("SSC")); - assertSame(fodDescriptor, context.getTransientSessionDescriptor("FoD")); + assertNull(context.getIsolationScope().getTransientSessionDescriptor("SSC")); + assertSame(fodDescriptor, context.getIsolationScope().getTransientSessionDescriptor("FoD")); - context.clearTransientSessionDescriptors(); + context.getIsolationScope().clearTransientSessionDescriptors(); - assertTrue(context.getTransientSessionDescriptors().isEmpty()); + assertTrue(context.getIsolationScope().getTransientSessionDescriptors().isEmpty()); } @Test @@ -57,19 +58,23 @@ void transientSessionDescriptorConvenienceSetterIndexesByType() { var context = new FcliExecutionContext(); var descriptor = new DummySessionDescriptor("dummy"); - context.setTransientSessionDescriptor(descriptor); + context.getIsolationScope().setTransientSessionDescriptor(descriptor); - assertSame(descriptor, context.getTransientSessionDescriptor("dummy")); + assertSame(descriptor, context.getIsolationScope().getTransientSessionDescriptor("dummy")); } @Test - void mcpRequestAuthScopeKeyIsFoundInNestedParentContext() { + void pushNewInheritsIsolationScopeButCreatesFreshActionState() { FcliExecutionContextHolder.pushNew(); try { - FcliExecutionContextHolder.current().setMcpRequestAuthScopeKey("ssc|abc123"); + var parent = FcliExecutionContextHolder.current(); + parent.getIsolationScope().setMcpRequestAuthScopeKey("ssc|abc123"); FcliExecutionContextHolder.pushNew(); try { + var child = FcliExecutionContextHolder.current(); assertEquals("ssc|abc123", FcliExecutionContextHolder.getMcpRequestAuthScopeKey()); + assertSame(parent.getIsolationScope(), child.getIsolationScope()); + assertTrue(child.getActionState().getGlobalActionValues().isEmpty()); } finally { FcliExecutionContextHolder.pop(); } @@ -78,6 +83,24 @@ void mcpRequestAuthScopeKeyIsFoundInNestedParentContext() { } } + @Test + void childContextsCanChooseFreshOrSharedActionState() { + var parent = new FcliExecutionContext(); + var freshChild = parent.createChild(); + var sharedChild = parent.createChildWithSharedActionState(); + + assertSame(parent.getIsolationScope(), freshChild.getIsolationScope()); + assertSame(parent.getIsolationScope(), sharedChild.getIsolationScope()); + assertTrue(freshChild.getActionState().getGlobalActionValues().isEmpty()); + assertSame(parent.getActionState(), sharedChild.getActionState()); + } + + @Test + void currentThrowsWhenNoContextHasBeenPushed() { + // Verify that current() never silently creates a context — callers must push explicitly. + assertThrows(IllegalStateException.class, FcliExecutionContextHolder::current); + } + private static final class DummySessionDescriptor implements ISessionDescriptor { private final String type; diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/FcliConcurrencyTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/FcliConcurrencyTest.java index e93e839fc1c..1e403ff2fa9 100644 --- a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/FcliConcurrencyTest.java +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/FcliConcurrencyTest.java @@ -42,7 +42,7 @@ public void actionVarsAreIsolatedPerInvocation() throws InterruptedException, Ex var cli = JsonHelper.getObjectMapper().createObjectNode(); var vars = new ActionRunnerVars(null, cli); vars.set("global.foo", new TextNode("v1")); - return FcliExecutionContextHolder.current().getGlobalActionValues().get("foo").asText(); + return FcliExecutionContextHolder.current().getActionState().getGlobalActionValues().get("foo").asText(); } finally { FcliExecutionContextHolder.pop(); } @@ -53,7 +53,7 @@ public void actionVarsAreIsolatedPerInvocation() throws InterruptedException, Ex var cli = JsonHelper.getObjectMapper().createObjectNode(); var vars = new ActionRunnerVars(null, cli); vars.set("global.foo", new TextNode("v2")); - return FcliExecutionContextHolder.current().getGlobalActionValues().get("foo").asText(); + return FcliExecutionContextHolder.current().getActionState().getGlobalActionValues().get("foo").asText(); } finally { FcliExecutionContextHolder.pop(); } diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/job/AsyncJobManagerIsolationScopeTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/job/AsyncJobManagerIsolationScopeTest.java new file mode 100644 index 00000000000..852967207cc --- /dev/null +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/concurrent/job/AsyncJobManagerIsolationScopeTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.concurrent.job; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.cli.util.FcliActionState; +import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.cli.util.FcliIsolationScope; +import com.fortify.cli.common.util.OutputHelper.Result; + +class AsyncJobManagerIsolationScopeTest { + @Test + void jobsAreTrackedWithinTheCurrentIsolationScope() { + var manager = new AsyncJobManager(); + var scopeOne = new FcliIsolationScope(); + var scopeTwo = new FcliIsolationScope(); + + try { + FcliExecutionContextHolder.push(new FcliExecutionContext(scopeOne, new FcliActionState())); + var jobId = manager.startBackground(AsyncJobManager.TaskDescriptor.builder() + .task(recordConsumer -> new Result(0, "", "")) + .description("scope-one-job") + .build()); + assertNotNull(manager.getJobInfo(jobId)); + FcliExecutionContextHolder.pop(); + + FcliExecutionContextHolder.push(new FcliExecutionContext(scopeTwo, new FcliActionState())); + try { + assertNull(manager.getJobInfo(jobId)); + } finally { + FcliExecutionContextHolder.pop(); + } + + FcliExecutionContextHolder.push(new FcliExecutionContext(scopeOne, new FcliActionState())); + try { + assertNotNull(manager.getJobInfo(jobId)); + } finally { + FcliExecutionContextHolder.pop(); + } + } finally { + manager.shutdown(); + } + } +} diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java index b525967f46b..fbbd37c1633 100644 --- a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java @@ -29,7 +29,7 @@ void transientSessionDescriptorIsPreferredOverPersistedLookup() { var transientDescriptor = new DummySessionDescriptor("transient"); FcliExecutionContextHolder.pushNew(); try { - FcliExecutionContextHolder.current().setTransientSessionDescriptor(transientDescriptor); + FcliExecutionContextHolder.current().getIsolationScope().setTransientSessionDescriptor(transientDescriptor); var result = supplier.getSessionDescriptor(); @@ -45,7 +45,7 @@ void persistedLookupIsUsedIfNoTransientDescriptorExistsForType() { var supplier = new DummySessionDescriptorSupplier(); FcliExecutionContextHolder.pushNew(); try { - FcliExecutionContextHolder.current().setTransientSessionDescriptor(new OtherSessionDescriptor()); + FcliExecutionContextHolder.current().getIsolationScope().setTransientSessionDescriptor(new OtherSessionDescriptor()); var result = supplier.getSessionDescriptor(); @@ -62,7 +62,7 @@ void transientSessionDescriptorIsFoundInNestedParentContext() { var transientDescriptor = new DummySessionDescriptor("transient"); FcliExecutionContextHolder.pushNew(); try { - FcliExecutionContextHolder.current().setTransientSessionDescriptor(transientDescriptor); + FcliExecutionContextHolder.current().getIsolationScope().setTransientSessionDescriptor(transientDescriptor); FcliExecutionContextHolder.pushNew(); try { var result = supplier.getSessionDescriptor(); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java index 4b21db41618..0675eae7fd1 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.CachingJobEventListener; import com.fortify.cli.common.concurrent.job.CompositeJobEventListener; import com.fortify.cli.common.concurrent.job.IJobEventListener; @@ -48,13 +49,10 @@ final class RPCJobEventListenerFactory { private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.SECONDS, Period.DAYS); - private final CachingJobEventListener cachingListener; private volatile RPCServer.RPCOutputWriter outputWriter; private volatile RPCPushJobEventListener pushListener; - RPCJobEventListenerFactory(CachingJobEventListener cachingListener) { - this.cachingListener = cachingListener; - } + RPCJobEventListenerFactory() {} void setOutputWriter(RPCServer.RPCOutputWriter writer) { this.outputWriter = writer; @@ -145,7 +143,7 @@ IJobEventListener createListener(CacheConfig cacheConfig, boolean push) { var pushListener = push ? resolvePushListener() : null; IJobEventListener caching = null; if (cacheConfig != null) { - caching = withTtlEviction(cachingListener, cacheConfig.ttl()); + caching = withTtlEviction(getCachingListener(), cacheConfig.ttl()); } if (caching != null && pushListener != null) { return new CompositeJobEventListener(caching, pushListener); @@ -193,8 +191,13 @@ public void onProgress(String jobId, String message) { @Override public void onJobComplete(String jobId, int exitCode, String stderr, String stdout) { delegate.onJobComplete(jobId, exitCode, stderr, stdout); - cachingListener.scheduleEviction(jobId, ttl); + getCachingListener().scheduleEviction(jobId, ttl); } }; } + + private CachingJobEventListener getCachingListener() { + return FcliExecutionContextHolder.current().getIsolationScope() + .getOrCreateScopedState(CachingJobEventListener.class, CachingJobEventListener::new); + } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetPage.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetPage.java index cd82d6c9e7b..de3a39327fe 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetPage.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetPage.java @@ -15,10 +15,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.CachingJobEventListener; import com.fortify.cli.common.json.JsonHelper; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -42,10 +42,7 @@ * @author Ruud Senden */ @Slf4j -@RequiredArgsConstructor public final class RPCMethodHandlerJobGetPage implements IRPCMethodHandler { - private final CachingJobEventListener cachingListener; - @Override public String description() { return "Retrieve a page of records by jobId from the cache; works for all async jobs"; @@ -73,10 +70,15 @@ public JsonNode execute(JsonNode params) throws RPCMethodException { log.debug("Getting page: jobId={} offset={} limit={}", jobId, offset, limit); - var pageResult = cachingListener.getPage(jobId, offset, limit); + var pageResult = getCachingListener().getPage(jobId, offset, limit); return toResponse(pageResult); } + private CachingJobEventListener getCachingListener() { + return FcliExecutionContextHolder.current().getIsolationScope() + .getOrCreateScopedState(CachingJobEventListener.class, CachingJobEventListener::new); + } + private ObjectNode toResponse(CachingJobEventListener.PageResult page) { var response = JsonHelper.getObjectMapper().createObjectNode(); response.put("status", page.getStatus()); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetStatus.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetStatus.java index 9c9fbf347f0..c550244cb8c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetStatus.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobGetStatus.java @@ -13,6 +13,7 @@ package com.fortify.cli.util.rpc_server.helper; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.concurrent.job.CachingJobEventListener; import com.fortify.cli.common.json.JsonHelper; @@ -42,7 +43,6 @@ @RequiredArgsConstructor public final class RPCMethodHandlerJobGetStatus implements IRPCMethodHandler { private final AsyncJobManager asyncJobManager; - private final CachingJobEventListener cachingListener; @Override public String description() { @@ -71,9 +71,14 @@ public JsonNode execute(JsonNode params) throws RPCMethodException { response.put("description", info.getDescription()); response.put("completed", info.isCompleted()); response.put("exitCode", info.getExitCode()); - response.put("cached", cachingListener.hasCache(jobId)); + response.put("cached", getCachingListener().hasCache(jobId)); response.put("createdMillis", info.getCreatedMillis()); } return response; } + + private CachingJobEventListener getCachingListener() { + return FcliExecutionContextHolder.current().getIsolationScope() + .getOrCreateScopedState(CachingJobEventListener.class, CachingJobEventListener::new); + } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobRemove.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobRemove.java index fb934ab6048..b73f04982b1 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobRemove.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerJobRemove.java @@ -13,6 +13,7 @@ package com.fortify.cli.util.rpc_server.helper; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.concurrent.job.CachingJobEventListener; import com.fortify.cli.common.json.JsonHelper; @@ -39,7 +40,6 @@ @RequiredArgsConstructor public final class RPCMethodHandlerJobRemove implements IRPCMethodHandler { private final AsyncJobManager asyncJobManager; - private final CachingJobEventListener cachingListener; @Override public String description() { @@ -67,10 +67,15 @@ public JsonNode execute(JsonNode params) throws RPCMethodException { } asyncJobManager.removeJob(jobId); - cachingListener.remove(jobId); + getCachingListener().remove(jobId); return result(true, jobId, "Job removed successfully"); } + private CachingJobEventListener getCachingListener() { + return FcliExecutionContextHolder.current().getIsolationScope() + .getOrCreateScopedState(CachingJobEventListener.class, CachingJobEventListener::new); + } + private static JsonNode result(boolean success, String jobId, String message) { var response = JsonHelper.getObjectMapper().createObjectNode(); response.put("success", success); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java index d61a06d0792..24eae82ac46 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java @@ -20,7 +20,9 @@ import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; +import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliIsolationScope; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.concurrent.job.CachingJobEventListener; @@ -45,16 +47,16 @@ public final class RPCMethodHandlerRegistry { private final Map handlers; private final AsyncJobManager asyncJobManager; - private final CachingJobEventListener cachingListener; + private final FcliIsolationScope isolationScope; private final RPCJobEventListenerFactory listenerFactory; private RPCMethodHandlerRegistry(Map handlers, AsyncJobManager asyncJobManager, - CachingJobEventListener cachingListener, + FcliIsolationScope isolationScope, RPCJobEventListenerFactory listenerFactory) { this.handlers = handlers; this.asyncJobManager = asyncJobManager; - this.cachingListener = cachingListener; + this.isolationScope = isolationScope; this.listenerFactory = listenerFactory; } @@ -71,7 +73,11 @@ public AsyncJobManager getAsyncJobManager() { } public CachingJobEventListener getCachingListener() { - return cachingListener; + return isolationScope.getOrCreateScopedState(CachingJobEventListener.class, CachingJobEventListener::new); + } + + FcliIsolationScope getIsolationScope() { + return isolationScope; } /** @@ -92,8 +98,8 @@ public static Builder builder(AsyncJobManager asyncJobManager) { @Slf4j public static final class Builder { private final AsyncJobManager asyncJobManager; - private final CachingJobEventListener cachingListener = new CachingJobEventListener(); - private final FcliExecutionContext sharedFunctionContext = new FcliExecutionContext(); + private final FcliIsolationScope sharedIsolationScope = new FcliIsolationScope(); + private final FcliExecutionContext sharedFunctionContext = new FcliExecutionContext(sharedIsolationScope, new FcliActionState()); private final Map handlers = new LinkedHashMap<>(); private final Map importedFunctions = new LinkedHashMap<>(); @@ -132,23 +138,23 @@ public Builder register(String methodName, IRPCMethodHandler handler) { } public RPCMethodHandlerRegistry build() { - var listenerFactory = new RPCJobEventListenerFactory(cachingListener); + var listenerFactory = new RPCJobEventListenerFactory(); register("rpc.listMethods", new RPCMethodHandlerListMethods(handlers)); register("fcli.buildInfo", new RPCMethodHandlerFcliInfo()); register("fcli.execute", new RPCMethodHandlerFcliExecute(asyncJobManager, listenerFactory)); register("fcli.listCommands", new RPCMethodHandlerFcliListCommands()); register("fcli.getCommandDetails", new RPCMethodHandlerFcliGetCommandDetails()); - register("job.getPage", new RPCMethodHandlerJobGetPage(cachingListener)); - register("job.getStatus", new RPCMethodHandlerJobGetStatus(asyncJobManager, cachingListener)); + register("job.getPage", new RPCMethodHandlerJobGetPage()); + register("job.getStatus", new RPCMethodHandlerJobGetStatus(asyncJobManager)); register("job.cancel", new RPCMethodHandlerJobCancel(asyncJobManager)); - register("job.remove", new RPCMethodHandlerJobRemove(asyncJobManager, cachingListener)); + register("job.remove", new RPCMethodHandlerJobRemove(asyncJobManager)); register("job.list", new RPCMethodHandlerJobList(asyncJobManager)); register("fn.call", new RPCMethodHandlerFnCall(importedFunctions, asyncJobManager, listenerFactory)); register("fn.list", new RPCMethodHandlerFnList(importedFunctions)); return new RPCMethodHandlerRegistry( - Collections.unmodifiableMap(handlers), asyncJobManager, cachingListener, listenerFactory); + Collections.unmodifiableMap(handlers), asyncJobManager, sharedIsolationScope, listenerFactory); } } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java index 18ff62ede41..439ea293b9e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java @@ -29,6 +29,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fortify.cli.common.cli.util.FcliActionState; +import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.json.JsonHelper; import lombok.extern.slf4j.Slf4j; @@ -220,7 +223,13 @@ private RPCResponse executeMethod(RPCRequest request) { } try { - JsonNode result = handler.execute(request.params()); + FcliExecutionContextHolder.push(new FcliExecutionContext(registry.getIsolationScope(), new FcliActionState())); + JsonNode result; + try { + result = handler.execute(request.params()); + } finally { + FcliExecutionContextHolder.pop(); + } return RPCResponse.success(request.id(), result); } catch (RPCMethodException e) { return RPCResponse.error(request.id(), e.toJsonRpcError()); From e7fdf384fec1895e46f4ce08f02c42afa6b11b6b Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 11 May 2026 13:50:46 +0200 Subject: [PATCH 13/55] chore: Improvements, fixes --- .../mcp/cli/cmd/AgentMCPStartHttpCommand.java | 10 +-- .../cli/cmd/AgentMCPStartStdioCommand.java | 18 ++-- .../MCPImportedActionMcpSpecsFactory.java | 8 +- .../cli/agent/mcp/helper/MCPJobManager.java | 5 +- ...CPServerHttpSessionDescriptorResolver.java | 21 +++-- .../cli/cmd/RunBuildTimeFcliAction.java | 5 +- .../action/runner/ActionFunctionExecutor.java | 50 +++++------ .../cli/common/cli/util/FcliActionState.java | 23 ++++- .../cli/util/FcliCommandExecutorFactory.java | 28 ++---- .../common/cli/util/FcliExecutionContext.java | 68 ++++++++++---- .../cli/util/FcliExecutionContextHolder.java | 48 ++++++++-- .../cli/util/FcliExecutionStrategy.java | 88 ++++++++++++++++--- .../common/cli/util/FcliIsolationScope.java | 27 ++++-- .../util/IFcliExecutionContextManager.java | 19 ++++ .../concurrent/job/AsyncJobManager.java | 57 ++++++------ .../concurrent/job/exec/FcliRunnerHelper.java | 2 - .../cli/util/FcliExecutionContextTest.java | 65 +++++++------- ...actSessionDescriptorSupplierMixinTest.java | 21 ----- .../cmd/MCPServerStartDeprecatedCommand.java | 4 +- .../cli/cmd/RPCServerStartCommand.java | 3 +- .../helper/RPCMethodHandlerRegistry.java | 8 +- .../cli/util/rpc_server/helper/RPCServer.java | 5 +- .../cli/ftest/core/ActionFunctionsSpec.groovy | 18 ++++ .../cli/ftest/core/MCPServerImportSpec.groovy | 29 ++++++ .../actions/run-fcli-shared-state-child.yaml | 13 +++ .../actions/run-fcli-shared-state-parent.yaml | 23 +++++ 26 files changed, 434 insertions(+), 232 deletions(-) create mode 100644 fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/IFcliExecutionContextManager.java create mode 100644 fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/run-fcli-shared-state-child.yaml create mode 100644 fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/run-fcli-shared-state-parent.yaml diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index f6156838084..41ba6330dd1 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -29,6 +29,7 @@ import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.cli.util.IFcliExecutionContextManager; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.mcp.MCPExclude; @@ -48,7 +49,7 @@ @Command(name = "start-http") @MCPExclude @Slf4j -public class AgentMCPStartHttpCommand extends AbstractRunnableCommand { +public class AgentMCPStartHttpCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); @Option(names = {"--config", "-c"}, required = true) @@ -78,7 +79,7 @@ public Integer call() throws Exception { var sessionDescriptorResolver = new MCPServerHttpSessionDescriptorResolver(config); var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, - () -> sessionDescriptorResolver.getOrCreateFunctionContext(FcliExecutionContextHolder.getMcpRequestAuthScopeKey())); + () -> sessionDescriptorResolver.getOrCreateFunctionFrame(FcliExecutionContextHolder.getMcpRequestAuthScopeKey())); var toolSpecs = new ArrayList(); var resourceTemplateSpecs = new ArrayList(); for ( var importPath : config.getResolvedImportPaths() ) { @@ -140,11 +141,8 @@ private T withRequestExecutionContext(McpTransportContext transportContext, Supplier supplier) { var isolationScope = sessionDescriptorResolver.getOrCreateIsolationScope(transportContext); - FcliExecutionContextHolder.push(new FcliExecutionContext(isolationScope, new FcliActionState())); - try { + try (var frame = FcliExecutionContextHolder.push(new FcliExecutionContext(isolationScope, new FcliActionState()))) { return supplier.get(); - } finally { - FcliExecutionContextHolder.pop(); } } diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java index 8efb7e496dd..804aa3c7093 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java @@ -55,6 +55,7 @@ import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.cli.util.FcliIsolationScope; +import com.fortify.cli.common.cli.util.IFcliExecutionContextManager; import com.fortify.cli.common.cli.util.StdioHelper; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.concurrent.job.cli.mixin.AsyncJobManagerMixin; @@ -87,7 +88,7 @@ @Command(name = "start-stdio") @MCPExclude // Doesn't make sense to allow mcp-server start command to be called from MCP server @Slf4j -public class AgentMCPStartStdioCommand extends AbstractRunnableCommand { +public class AgentMCPStartStdioCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { @Option(names={"--module", "-m"}, required = false) private McpModule module; @DisableTest(TestType.MULTI_OPT_PLURAL_NAME) @Option(names={"--import"}, split=",") private List importFiles; @@ -99,7 +100,9 @@ public class AgentMCPStartStdioCommand extends AbstractRunnableCommand { private static final AsyncJobManager.Config MCP_ASYNC_DEFAULTS = AsyncJobManager.Config.builder().build(); private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); private final FcliIsolationScope sharedIsolationScope = new FcliIsolationScope(); - private final FcliExecutionContext sharedFunctionContext = new FcliExecutionContext(sharedIsolationScope, new FcliActionState()); + private final FcliActionState sharedFunctionActionState = new FcliActionState(); + private final Supplier sharedFunctionFrameSupplier = + () -> FcliExecutionContextHolder.push(new FcliExecutionContext(sharedIsolationScope, sharedFunctionActionState)); private MCPJobManager jobManager; @Override @@ -249,11 +252,8 @@ private SyncResourceTemplateSpecification wrapResourceTemplateSpec(SyncResourceT } private T withSharedExecutionContext(Supplier supplier) { - FcliExecutionContextHolder.push(new FcliExecutionContext(sharedIsolationScope, new FcliActionState())); - try { + try (var frame = FcliExecutionContextHolder.push(new FcliExecutionContext(sharedIsolationScope, new FcliActionState()))) { return supplier.get(); - } finally { - FcliExecutionContextHolder.pop(); } } @@ -269,7 +269,7 @@ private List createImportedFunctionToolSpecs(Action actio var function = entry.getValue(); if (!function.isExported()) { continue; } if (hasMcpResourceMeta(function)) { continue; } // Resources handled separately - var executor = new ActionFunctionExecutor(action, function, sharedFunctionContext); + var executor = new ActionFunctionExecutor(action, function, sharedFunctionFrameSupplier); var toolName = "fcli_fn_" + function.getKey().replace('-', '_'); var schema = buildFunctionArgsSchema(function); var description = function.getDescription() != null ? function.getDescription() : function.getKey(); @@ -289,7 +289,7 @@ private List createImportedFunctionToolSpecs(Action actio .build(); result.add(McpServerFeatures.SyncToolSpecification.builder() .tool(tool) - .callHandler(runner::run) + .callHandler((ctx, request) -> withSharedExecutionContext(() -> runner.run(ctx, request))) .build()); log.debug("Registering function tool: {} (streaming={})", toolName, function.isStreaming()); } @@ -307,7 +307,7 @@ private List createImportedFunctionResourceTe if (uriTemplate == null) { continue; } var name = getMetaString(resourceMeta, "name"); var mimeType = getMetaString(resourceMeta, "mime-type"); - var executor = new ActionFunctionExecutor(action, function, sharedFunctionContext); + var executor = new ActionFunctionExecutor(action, function, sharedFunctionFrameSupplier); var template = ResourceTemplate.builder() .uriTemplate(uriTemplate) .name(name != null ? name : function.getKey()) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java index c18b469aa36..4369d837961 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java @@ -27,7 +27,7 @@ import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; import com.fortify.cli.common.action.model.ActionFunction; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; -import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import io.modelcontextprotocol.server.McpStatelessServerFeatures; import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification; @@ -40,7 +40,7 @@ @RequiredArgsConstructor public class MCPImportedActionMcpSpecsFactory { private final MCPJobManager jobManager; - private final Supplier functionContextSupplier; + private final Supplier frameSupplier; public ImportedSpecs create(Path importFile) { var action = ActionLoaderHelper.load( @@ -66,7 +66,7 @@ public ImportedSpecs create(Path importFile) { } private SyncToolSpecification createToolSpec(com.fortify.cli.common.action.model.Action action, String functionName, ActionFunction function) { - var executor = new ActionFunctionExecutor(action, function, functionContextSupplier); + var executor = new ActionFunctionExecutor(action, function, frameSupplier); var toolName = "fcli_fn_" + functionName.replace('-', '_'); var schema = buildFunctionArgsSchema(function); var description = function.getDescription() != null ? function.getDescription() : functionName; @@ -96,7 +96,7 @@ private SyncResourceTemplateSpecification createResourceTemplateSpec(com.fortify var uriTemplate = getMetaString(resourceMeta, "uri-template"); var name = getMetaString(resourceMeta, "name"); var mimeType = getMetaString(resourceMeta, "mime-type"); - var executor = new ActionFunctionExecutor(action, function, functionContextSupplier); + var executor = new ActionFunctionExecutor(action, function, frameSupplier); var template = ResourceTemplate.builder() .uriTemplate(uriTemplate) .name(name != null ? name : functionName) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java index d3fa2f023de..36c9710fedc 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java @@ -109,11 +109,8 @@ private CompletableFuture startJobExecution(McpSyncServerExchang // the same isolation scope (auth scope key, transient sessions) when executing the work. var parentContext = FcliExecutionContextHolder.current(); CompletableFuture future = CompletableFuture.supplyAsync(() -> { - FcliExecutionContextHolder.push(parentContext.createChild()); - try { + try (var frame = FcliExecutionContextHolder.push(parentContext.createChild())) { return executeWork(exchange, exec, work, sendNotifications); - } finally { - FcliExecutionContextHolder.pop(); } }, workExecutor) .whenComplete((res, t) -> handleJobCompletion(exchange, exec, res, t, sendNotifications)); diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index be93353fc88..9a7efe44a0f 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -28,6 +28,7 @@ import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.cli.util.FcliIsolationScope; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.rest.unirest.config.UrlConfig; @@ -82,11 +83,7 @@ protected boolean removeEldestEntry(Map.Entry eldest } }; private static final class FunctionContextState { - private final FcliExecutionContext context; - - private FunctionContextState(FcliIsolationScope isolationScope) { - this.context = new FcliExecutionContext(isolationScope, new FcliActionState()); - } + private final FcliActionState actionState = new FcliActionState(); } public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext transportContext) { @@ -97,14 +94,16 @@ public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext trans } /** - * Returns the {@link FcliExecutionContext} for the given auth scope key, creating one - * on first use. Each distinct auth identity gets its own context so that - * {@code global.*} action variables are not shared across different callers. + * Pushes a new {@link FcliExecutionContext} (fresh {@code UnirestContext}) for the given + * auth scope key and returns the associated {@link FcliExecutionContextHolder.ContextFrame}. + * The per-auth-scope {@link FcliActionState} is reused across calls so that + * {@code global.*} action variables persist within the same authenticated identity. */ - public FcliExecutionContext getOrCreateFunctionContext(String authScopeKey) { + public FcliExecutionContextHolder.ContextFrame getOrCreateFunctionFrame(String authScopeKey) { var isolationScope = getOrCreateIsolationScope(authScopeKey); - return isolationScope.getOrCreateScopedState(FunctionContextState.class, - () -> new FunctionContextState(isolationScope)).context; + var actionState = isolationScope.getOrCreateScopedState(FunctionContextState.class, + FunctionContextState::new).actionState; + return FcliExecutionContextHolder.push(new FcliExecutionContext(isolationScope, actionState)); } public FcliIsolationScope getOrCreateIsolationScope(McpTransportContext transportContext) { diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java index 7cec81e8b37..cb108dd6578 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java @@ -61,11 +61,8 @@ public static void main(String[] args) { .progressWriter(progressWriter) .onValidationErrors(RunBuildTimeFcliAction::onValidationErrors) .build(); - FcliExecutionContextHolder.pushNew(); - try { + try (var frame = FcliExecutionContextHolder.pushNew()) { new ActionRunner(config).run(actionArgs); - } finally { - FcliExecutionContextHolder.pop(); } } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java index 977a219a3e7..dbe166eaa81 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java @@ -18,7 +18,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.action.model.Action; import com.fortify.cli.common.action.model.ActionFunction; -import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.progress.helper.ProgressWriterI18n; @@ -26,38 +25,34 @@ /** * Thread-safe executor for a single action function. Creates a fresh - * {@link ActionRunnerContextLocal} per invocation, builds the args ObjectNode, - * and delegates to {@link ActionFunctionSpelFunctions#call(String, Object...)}. - *

- * A {@link Supplier} of {@link FcliExecutionContext} is evaluated on each invocation - * to determine which context to push. For RPC/stdio servers this supplier typically - * returns the same shared instance (so {@code globalActionValues} persist across - * invocations). For HTTP servers the supplier can return a per-auth-scope context - * so that different auth identities have isolated {@code globalActionValues}. - *

+ * {@link ActionRunnerContextLocal} per invocation and delegates to + * {@link ActionFunctionSpelFunctions#call(String, Object...)}. + * + *

The caller supplies a {@code Supplier} that is responsible for + * pushing the correct {@link com.fortify.cli.common.cli.util.FcliExecutionContext} + * onto the thread-local stack and returning the associated + * {@link FcliExecutionContextHolder.ContextFrame}. Typical patterns:

+ *
    + *
  • MCP stdio / RPC server — the supplier captures a shared + * {@link com.fortify.cli.common.cli.util.FcliActionState} and pushes a new + * {@code FcliExecutionContext} (fresh {@code UnirestContext}) each call, so + * connections are always clean while {@code global.*} variables persist across + * calls within the same server instance.
  • + *
  • MCP HTTP server — the supplier resolves the per-auth-scope action state + * and isolation scope at call time, providing full isolation between different + * authenticated identities.
  • + *
* Used by MCP/RPC server implementations to invoke exported functions. */ public final class ActionFunctionExecutor { private final Action action; private final ActionFunction function; - private final Supplier contextSupplier; + private final Supplier frameSupplier; - /** - * Create an executor that resolves its execution context via a supplier on - * each invocation. - */ - public ActionFunctionExecutor(Action action, ActionFunction function, Supplier contextSupplier) { + public ActionFunctionExecutor(Action action, ActionFunction function, Supplier frameSupplier) { this.action = action; this.function = function; - this.contextSupplier = contextSupplier; - } - - /** - * Convenience constructor that wraps a fixed, shared {@link FcliExecutionContext} - * so that all invocations use the same context (original behaviour for RPC/stdio). - */ - public ActionFunctionExecutor(Action action, ActionFunction function, FcliExecutionContext sharedContext) { - this(action, function, () -> sharedContext); + this.frameSupplier = frameSupplier; } public Action getAction() { @@ -77,8 +72,7 @@ public ActionFunction getFunction() { * For streaming functions: an IActionStepForEachProcessor. */ public Object execute(ObjectNode argsNode) { - FcliExecutionContextHolder.push(contextSupplier.get().createChildWithSharedActionState()); - try { + try (var frame = frameSupplier.get()) { var config = ActionRunnerConfig.builder() .action(action) .progressWriter(new ProgressWriterI18n(ProgressWriterType.none, null)) @@ -89,8 +83,6 @@ public Object execute(ObjectNode argsNode) { var fnSpel = new ActionFunctionSpelFunctions(ctx); return fnSpel.call(function.getKey(), argsNode); } - } finally { - FcliExecutionContextHolder.pop(); } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java index 952e7c9d517..5c92481f254 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java @@ -20,11 +20,26 @@ import lombok.Getter; /** - * Mutable state shared by related action or function invocations. + * Mutable bag of {@code global.*} action variables shared by related action invocations. * - *

This state intentionally lives outside {@link FcliExecutionContext} so that - * callers can share {@code global.*} values across imported function invocations - * while still creating a fresh execution frame for each invocation.

+ *

Instances of this class are deliberately decoupled from {@link FcliExecutionContext} + * so that the sharing rules for {@code global.*} variables can be configured independently + * from the sharing rules for other per-execution resources: + * + *

    + *
  • External CLI invocation — a fresh {@code FcliActionState} is created for every + * top-level command, so {@code global.*} variables cannot leak from one CLI call to the + * next.
  • + *
  • MCP / RPC tool call (non-imported) — each tool call also gets a fresh + * {@code FcliActionState}, keeping calls independent.
  • + *
  • Imported action functions (MCP stdio / RPC) — all invocations within the same + * server instance share one {@code FcliActionState} instance. This is the mechanism that + * lets one exported function set a {@code global.*} variable that a subsequent call to a + * different exported function can read back.
  • + *
  • {@code run.fcli} sub-commands — executed within the parent's existing + * {@link FcliExecutionContext}, so they see and can mutate the same + * {@code FcliActionState} as the calling action step.
  • + *
*/ public final class FcliActionState { @Getter private final ObjectNode globalActionValues = new ObjectNode(JsonNodeFactory.instance, new ConcurrentHashMap<>()); diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliCommandExecutorFactory.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliCommandExecutorFactory.java index 1ac0865ba0c..ccb8d9ab0bb 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliCommandExecutorFactory.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliCommandExecutorFactory.java @@ -65,7 +65,6 @@ public FcliCommandExecutorFactoryBuilder stdoutOutputType(OutputType type) { private final Consumer onSuccess; // Executed after onResult, if 0 exit code private final Consumer onFail; // Executed after onResult, if non-zero exit code private final Consumer onException; - @Builder.Default private final boolean createInvocationContext = false; public final String progressOptionValueIfNotPresent; // TODO Should we integrate this into defaultOptionsIfNotPresent? public final Map defaultOptionsIfNotPresent; @@ -122,26 +121,17 @@ public final Result execute() { private Result call(Callable f) { Result result = null; - boolean pushed = false; try { - if ( createInvocationContext ) { - FcliExecutionContextHolder.pushNew(); - pushed = true; + result = OutputHelper.builder() + .stderrType(stderrOutputType) + .stdoutType(resolveStdoutOutputType()) + .build().call(f); + } catch ( Throwable t ) { + if ( t instanceof ExecutionException ) { + t = t.getCause(); } - try { - result = OutputHelper.builder() - .stderrType(stderrOutputType) - .stdoutType(resolveStdoutOutputType()) - .build().call(f); - } catch ( Throwable t ) { - if ( t instanceof ExecutionException ) { - t = t.getCause(); - } - consume(t, onException, this::rethrowAsRuntimeException); - return new Result(999, "", ""); - } - } finally { - if ( pushed ) { FcliExecutionContextHolder.pop(); } + consume(t, onException, this::rethrowAsRuntimeException); + return new Result(999, "", ""); } // We want result processing to be outside of the try/catch block above, // as any of these may throw an exception that we don't want to catch in diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java index a1464e19e87..2c3f2c0eb3f 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java @@ -10,14 +10,12 @@ * herein. The information contained herein is subject to change * without notice. */ -/* - * Copyright 2021-2026 Open Text. - */ package com.fortify.cli.common.cli.util; import java.nio.file.Path; import java.security.SecureRandom; import java.util.Base64; +import java.util.Objects; import java.util.Set; import com.fortify.cli.common.crypto.helper.EncryptionHelper; @@ -26,16 +24,42 @@ import lombok.Getter; /** - * Execution-frame local state for a single invocation. + * Per-invocation execution frame holding the three components of execution state: + * + *
    + *
  • {@link UnirestContext} — always fresh for every external entry point (plain CLI + * command, MCP tool call, RPC method call). A fresh context ensures that Unirest + * connection pools created in one invocation are shut down before the next one begins, + * so that any session changes made between invocations (login/logout, credential rotation, + * URL change) are always picked up rather than silently reused from a stale connection. + * Inner sub-commands executed via {@code run.fcli} reuse the parent's UnirestContext + * because they run within the same execution frame.
  • + * + *
  • {@link FcliActionState} — holds {@code global.*} action variables. Isolation + * rules vary by call site: + *
      + *
    • Each external CLI invocation and each top-level MCP/RPC tool call starts with a + * fresh {@code FcliActionState}, so {@code global.*} variables never leak between + * independent calls.
    • + *
    • Imported functions (e.g. exported action functions served as MCP/RPC tools) share + * the same {@code FcliActionState} across calls within the lifetime of the same server instance, so + * that one function can set a variable that a later function call reads back.
    • + *
    • Inner action invocations triggered via {@code run.fcli} inherit the parent frame's + * {@code FcliActionState}, giving them read/write access to the same + * {@code global.*} map as their caller.
    • + *
  • + * + *
  • {@link FcliIsolationScope} — groups related invocations that share the same + * auth/session boundary. See {@link FcliIsolationScope} for details. + *
* - *

Each execution context owns resources that must not be shared across - * independent invocations, such as the per-execution {@link UnirestContext} and - * ephemeral encryption state. Longer-lived isolation concerns like request/auth - * scoped caches and transient session descriptors are kept in the associated - * {@link FcliIsolationScope}, while shared action variables live in the - * associated {@link FcliActionState}.

+ *

The preferred way to manage context lifetime is through + * {@link FcliExecutionContextHolder#push} / {@link FcliExecutionContextHolder#pushNew}, + * which return a {@link FcliExecutionContextHolder.ContextFrame} that can be used with + * try-with-resources. Closing the frame pops the context from the stack and calls + * {@link #close()}, which shuts down the owned {@link UnirestContext}.

*/ -public final class FcliExecutionContext { +public final class FcliExecutionContext implements AutoCloseable { @Getter private final FcliIsolationScope isolationScope; @Getter private final FcliActionState actionState; @Getter private final UnirestContext unirestContext = new UnirestContext(); @@ -49,24 +73,30 @@ public FcliExecutionContext() { } public FcliExecutionContext(FcliIsolationScope isolationScope, FcliActionState actionState) { - this.isolationScope = isolationScope == null ? new FcliIsolationScope() : isolationScope; - this.actionState = actionState == null ? new FcliActionState() : actionState; + this.isolationScope = Objects.requireNonNull(isolationScope, "isolationScope"); + this.actionState = Objects.requireNonNull(actionState, "actionState"); } /** - * Create a child execution frame that inherits the current isolation scope - * while starting with a fresh action state. + * Create a child execution frame that inherits the current isolation scope but + * starts with a fresh {@link FcliActionState}. + * + *

Used when the child must share the same auth/session boundary as its parent + * (e.g. worker threads in {@code MCPJobManager} and {@code AsyncJobManager}) but + * must not see or mutate the parent's {@code global.*} action variables.

*/ public FcliExecutionContext createChild() { return new FcliExecutionContext(isolationScope, new FcliActionState()); } /** - * Create a child execution frame that inherits both the isolation scope and - * shared action state. + * Releases resources held by this execution context, specifically shutting down + * all cached {@link UnirestContext} connections. Should be called by the entry + * point that pushed this context once execution is complete. */ - public FcliExecutionContext createChildWithSharedActionState() { - return new FcliExecutionContext(isolationScope, actionState); + @Override + public void close() { + unirestContext.close(); } public String info() { diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java index dbdd878d329..1df8882fca3 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java @@ -26,33 +26,63 @@ * {@link #current()} — typically at the entry point of each execution path * (plain CLI via {@link FcliExecutionStrategy}, MCP request handlers, RPC * request dispatch). Callers should never rely on automatic context creation.

+ * + *

The preferred idiom for managing context lifetime is try-with-resources + * using the {@link ContextFrame} returned by {@link #push} and {@link #pushNew}:

+ *
{@code
+ * try (var frame = FcliExecutionContextHolder.push(ctx)) {
+ *     // use frame.context() if needed
+ * } // automatically pops and closes
+ * }
*/ public final class FcliExecutionContextHolder { private static final ThreadLocal> HOLDER = ThreadLocal.withInitial(ArrayDeque::new); private FcliExecutionContextHolder() {} - /** Push the given context onto the current thread's context stack. */ - public static void push(FcliExecutionContext ctx) { HOLDER.get().push(ctx); } + /** + * Handle returned by {@link #push} and {@link #pushNew}. + * Closing this frame pops the associated context from the stack and closes it, + * releasing any resources it holds (e.g. cached Unirest connections). + */ + public record ContextFrame(FcliExecutionContext context) implements AutoCloseable { + @Override public void close() { pop(); } + } + + /** Push the given context onto the current thread's context stack and return a closeable frame. */ + public static ContextFrame push(FcliExecutionContext ctx) { + HOLDER.get().push(ctx); + return new ContextFrame(ctx); + } /** - * Push a fresh execution frame, inheriting the current isolation scope when - * a parent context is present so nested invocations remain within the same - * isolation boundary while still receiving a fresh action state. + * Push a brand-new execution frame with its own isolation scope and action state. + * + *

Always creates a completely fresh {@link FcliExecutionContext} regardless of + * whether a parent context is present on the stack. Use this at top-level entry points + * (plain CLI via {@link FcliExecutionStrategy}, build-time actions) where no inherited + * state is desired.

+ * + *

Worker threads that need to share the parent's isolation scope should instead call + * {@link #push(FcliExecutionContext)} with {@code parentContext.createChild()}.

*/ - public static FcliExecutionContext pushNew() { + public static ContextFrame pushNew() { var stack = HOLDER.get(); - var context = stack.isEmpty() ? new FcliExecutionContext() : stack.peek().createChild(); + var context = new FcliExecutionContext(); stack.push(context); - return context; + return new ContextFrame(context); } - /** Pop the current context and return it; returns null if none present. */ + /** + * Pop the current context, close it, and return it. + * Returns {@code null} if no context is present. + */ public static FcliExecutionContext pop() { var stack = HOLDER.get(); if ( stack.isEmpty() ) { return null; } var result = stack.pop(); if ( stack.isEmpty() ) { HOLDER.remove(); } + result.close(); return result; } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java index e184c7e77a2..3c226b37202 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java @@ -15,6 +15,7 @@ import java.lang.reflect.Field; import com.fortify.cli.common.cli.mixin.ICommandAware; +import com.fortify.cli.common.exception.FcliBugException; import com.fortify.cli.common.log.LogMaskHelper; import com.fortify.cli.common.log.LogMaskSource; import com.fortify.cli.common.log.MaskValue; @@ -29,18 +30,38 @@ import picocli.CommandLine.ParseResult; /** - * Combined execution strategy performing command initialization formerly handled by - * AbstractRunnableCommand::initialize(), together with UnirestContext lifecycle management. + * Picocli execution strategy that initialises every command and owns the + * {@link FcliExecutionContext} lifecycle for plain CLI invocations. * - * Responsibilities executed exactly once per leaf command invocation: + *

Execution context lifecycle

+ *

This strategy is the single place responsible for deciding whether a new + * {@link FcliExecutionContext} must be created and who owns its lifetime. There + * are three cases, each handled by a dedicated private method:

+ *
    + *
  1. Context-manager commands ({@link IFcliExecutionContextManager}) — long-running + * server-start commands (MCP, RPC) that manage their own per-request context lifecycle. + * The strategy must not push any context for these commands and will throw + * {@link com.fortify.cli.common.exception.FcliBugException} if one is already on the + * stack (which would indicate the server-start command was incorrectly invoked from + * within an existing context).
  2. + *
  3. Root commands (stack empty at entry) — a plain, top-level CLI invocation such + * as {@code fcli ssc session login}. The strategy pushes a brand-new + * {@link FcliExecutionContext} (fresh {@link com.fortify.cli.common.rest.unirest.UnirestContext} + * and fresh {@link FcliActionState}), executes the command, then pops and closes the + * context, shutting down all Unirest connection pools.
  4. + *
  5. Nested commands (stack non-empty at entry) — a sub-command triggered by an + * action step's {@code run.fcli}. The strategy reuses the existing context so that the + * sub-command inherits the parent's open connections and {@code global.*} action + * variables.
  6. + *
+ * + *

Other responsibilities

+ *

Regardless of the execution path, this strategy also:

*
    - *
  • Register log masks for option values
  • - *
  • Inject CommandSpec into all ICommandAware mixins
  • - *
  • Log fcli version and arguments
  • - *
  • Create & inject UnirestContext into all IUnirestContextAware components
  • + *
  • Registers log masks for sensitive option values
  • + *
  • Injects {@code CommandSpec} into all {@link ICommandAware} mixins
  • + *
  • Logs the fcli version and command arguments
  • *
- * A single iteration over all user objects is used to inject both CommandSpec and UnirestContext - * for performance. */ @Slf4j public final class FcliExecutionStrategy implements IExecutionStrategy { @@ -54,17 +75,56 @@ public FcliExecutionStrategy(IExecutionStrategy delegate) { public int execute(ParseResult parseResult) throws CommandLine.ExecutionException { var leaf = getLeafParseResult(parseResult); var leafSpec = leaf.commandSpec(); - // Push a context for this command execution. pushNew() inherits the current - // isolation scope when called from inside a server request (MCP/RPC), or - // creates a fresh root context for plain CLI invocations. - var execCtx = FcliExecutionContextHolder.pushNew(); + if (leafSpec.userObject() instanceof IFcliExecutionContextManager) { + // Server-start command: manages its own per-request context; strategy must not interfere + return executeContextManager(parseResult, leafSpec); + } else if (FcliExecutionContextHolder.stackDepth() == 0) { + // Top-level CLI invocation: push a fresh context and close it after the command exits + return executeRootCommand(parseResult, leafSpec); + } else { + // Nested invocation via run.fcli: reuse the parent context so connections and + // global.* variables are shared with the calling action step + return executeNestedCommand(parseResult, leafSpec); + } + } + + private int executeContextManager(ParseResult parseResult, CommandSpec leafSpec) throws CommandLine.ExecutionException { + int stackDepth = FcliExecutionContextHolder.stackDepth(); + if (stackDepth > 0) { + throw new FcliBugException( + "IFcliExecutionContextManager command '%s' must not be invoked from within an existing execution context (stack depth=%d); these commands manage their own context lifecycle", + leafSpec.qualifiedName(), stackDepth); + } + log.debug("Starting command execution (context-manager); command={}", leafSpec.qualifiedName()); try { + initializeCommand(leafSpec); + return delegate.execute(parseResult); + } finally { + log.debug("Finished command execution (context-manager); command={}", leafSpec.qualifiedName()); + } + } + + private int executeRootCommand(ParseResult parseResult, CommandSpec leafSpec) throws CommandLine.ExecutionException { + try (var frame = FcliExecutionContextHolder.pushNew()) { + var execCtx = frame.context(); log.debug("Starting command execution; execInfo={} command={}", execCtx.info(), leafSpec.qualifiedName()); + try { + initializeCommand(leafSpec); + return delegate.execute(parseResult); + } finally { + log.debug("Finished command execution; execInfo={} command={}", execCtx.info(), leafSpec.qualifiedName()); + } + } + } + + private int executeNestedCommand(ParseResult parseResult, CommandSpec leafSpec) throws CommandLine.ExecutionException { + var execCtx = FcliExecutionContextHolder.current(); + log.debug("Starting command execution; execInfo={} command={}", execCtx.info(), leafSpec.qualifiedName()); + try { initializeCommand(leafSpec); return delegate.execute(parseResult); } finally { log.debug("Finished command execution; execInfo={} command={}", execCtx.info(), leafSpec.qualifiedName()); - FcliExecutionContextHolder.pop(); } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java index 9cee234c3e9..b21cd0315ff 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java @@ -21,13 +21,28 @@ import lombok.Getter; /** - * Shared isolation boundary for request, auth, session, and cache scoped state. + * Shared isolation boundary grouping related invocations under the same auth/session context. * - *

Execution frames represented by {@link FcliExecutionContext} may be created - * and discarded frequently, but related invocations can still share the same - * isolation scope. This allows nested command invocations and background jobs to - * resolve the same request/auth scoped caches and transient session descriptors - * without reusing the same execution frame.

+ *

An isolation scope lives longer than a single {@link FcliExecutionContext}: many execution + * frames can be created and destroyed while still referring to the same scope, allowing nested + * commands and background jobs to resolve the same request-scoped caches and transient session + * descriptors without sharing a single execution frame.

+ * + *

Scope assignment per execution model:

+ *
    + *
  • Plain CLI command — one brand-new scope per invocation; discarded when the + * command exits.
  • + *
  • MCP stdio server — one scope for the entire server lifetime, shared by all + * tool calls. Every tool call gets its own {@link FcliExecutionContext} (fresh + * {@link com.fortify.cli.common.rest.unirest.UnirestContext}) but they all share the + * same transient sessions and scope-scoped state.
  • + *
  • MCP HTTP server — one scope per authenticated identity. The HTTP + * transport carries credentials with every request; the server resolves the corresponding + * scope via {@code MCPServerHttpSessionDescriptorResolver}, so that two clients using + * different credentials are fully isolated from each other even when served by the same + * JVM.
  • + *
  • RPC server — one scope for the entire server lifetime, similar to MCP stdio.
  • + *
*/ public final class FcliIsolationScope { @Getter private volatile String mcpRequestAuthScopeKey; diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/IFcliExecutionContextManager.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/IFcliExecutionContextManager.java new file mode 100644 index 00000000000..8afade35154 --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/IFcliExecutionContextManager.java @@ -0,0 +1,19 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +/** + * Marker interface for commands that manage execution-context lifecycle + * themselves, for example long-running RPC/MCP server start commands. + */ +public interface IFcliExecutionContextManager {} \ No newline at end of file diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java index 2534ca1a273..4dc8f1a74f9 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/AsyncJobManager.java @@ -106,38 +106,37 @@ public String startBackground(TaskDescriptor descriptor) { var future = CompletableFuture.runAsync(() -> { entry.thread = Thread.currentThread(); - FcliExecutionContextHolder.push(executionContextSupplier != null ? executionContextSupplier.get() : parentContext.createChild()); - // Register per-thread progress callback so that progress writer - // messages are forwarded to the job event listener as notifications. - // Masking is applied by StdioHelper before invoking the callback. - StdioHelper.setProgressCallback(msg -> - listener.onProgress(jobId, msg)); - try { - var result = task.run(record -> { + try (var frame = FcliExecutionContextHolder.push(executionContextSupplier != null ? executionContextSupplier.get() : parentContext.createChild())) { + // Register per-thread progress callback so that progress writer + // messages are forwarded to the job event listener as notifications. + // Masking is applied by StdioHelper before invoking the callback. + StdioHelper.setProgressCallback(msg -> listener.onProgress(jobId, msg)); + try { + var result = task.run(record -> { + if (!Thread.currentThread().isInterrupted()) { + listener.onRecord(jobId, record); + } + }); if (!Thread.currentThread().isInterrupted()) { - listener.onRecord(jobId, record); + int exitCode = result.getExitCode(); + String stderr = result.getErr(); + String stdout = result.getOut(); + if (stdout != null && !stdout.isBlank()) { + entry.stdout = stdout; + } + entry.exitCode = exitCode; + entry.stderr = stderr; + listener.onJobComplete(jobId, exitCode, stderr, stdout); } - }); - if (!Thread.currentThread().isInterrupted()) { - int exitCode = result.getExitCode(); - String stderr = result.getErr(); - String stdout = result.getOut(); - if (stdout != null && !stdout.isBlank()) { - entry.stdout = stdout; - } - entry.exitCode = exitCode; - entry.stderr = stderr; - listener.onJobComplete(jobId, exitCode, stderr, stdout); + } catch (Exception e) { + log.error("Async job failed: jobId={}", jobId, e); + entry.exitCode = 999; + entry.stderr = e.getMessage() != null ? e.getMessage() : "Async job failed"; + listener.onJobComplete(jobId, 999, entry.stderr, null); + } finally { + StdioHelper.clearProgressCallback(); + entry.completed = true; } - } catch (Exception e) { - log.error("Async job failed: jobId={}", jobId, e); - entry.exitCode = 999; - entry.stderr = e.getMessage() != null ? e.getMessage() : "Async job failed"; - listener.onJobComplete(jobId, 999, entry.stderr, null); - } finally { - StdioHelper.clearProgressCallback(); - entry.completed = true; - FcliExecutionContextHolder.pop(); } }, backgroundExecutor); diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliRunnerHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliRunnerHelper.java index 093dfc22f6e..49c53836ffd 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliRunnerHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/exec/FcliRunnerHelper.java @@ -45,7 +45,6 @@ public static Result collectStdout(String fullCmd, Map defaultOp .cmd(fullCmd) .stdoutOutputType(OutputType.collect) .stderrOutputType(OutputType.collect) - .createInvocationContext(true) .onFail(r -> {}); if (defaultOptions != null) { @@ -71,7 +70,6 @@ public static Result collectRecords(String fullCmd, Consumer recordC .stdoutOutputTypeIfRecordCollectionSupported(OutputType.suppress) .stdoutOutputTypeIfRecordCollectionNotSupported(OutputType.collect) .stderrOutputType(OutputType.collect) - .createInvocationContext(true) .recordConsumer(recordConsumer) .onFail(r -> {}); diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java index 941fab392a6..26b8cff382c 100644 --- a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java @@ -12,7 +12,6 @@ */ package com.fortify.cli.common.cli.util; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -28,43 +27,45 @@ class FcliExecutionContextTest { @Test void transientSessionDescriptorsCanBeStoredByTypeAndCleared() { - var context = new FcliExecutionContext(); - var sscDescriptor = new DummySessionDescriptor("SSC"); - var fodDescriptor = new DummySessionDescriptor("FoD"); + try (var context = new FcliExecutionContext()) { + var sscDescriptor = new DummySessionDescriptor("SSC"); + var fodDescriptor = new DummySessionDescriptor("FoD"); - assertTrue(context.getIsolationScope().getTransientSessionDescriptors().isEmpty()); - assertNull(context.getIsolationScope().getTransientSessionDescriptor("SSC")); - assertFalse(context.info().contains("transientSessions=1")); + assertTrue(context.getIsolationScope().getTransientSessionDescriptors().isEmpty()); + assertNull(context.getIsolationScope().getTransientSessionDescriptor("SSC")); + assertFalse(context.info().contains("transientSessions=1")); - context.getIsolationScope().setTransientSessionDescriptor(sscDescriptor); - context.getIsolationScope().setTransientSessionDescriptor(fodDescriptor); + context.getIsolationScope().setTransientSessionDescriptor(sscDescriptor); + context.getIsolationScope().setTransientSessionDescriptor(fodDescriptor); - assertSame(sscDescriptor, context.getIsolationScope().getTransientSessionDescriptor("SSC")); - assertSame(fodDescriptor, context.getIsolationScope().getTransientSessionDescriptor("FoD")); - assertTrue(context.info().contains("transientSessions=2")); + assertSame(sscDescriptor, context.getIsolationScope().getTransientSessionDescriptor("SSC")); + assertSame(fodDescriptor, context.getIsolationScope().getTransientSessionDescriptor("FoD")); + assertTrue(context.info().contains("transientSessions=2")); - context.getIsolationScope().clearTransientSessionDescriptor("SSC"); + context.getIsolationScope().clearTransientSessionDescriptor("SSC"); - assertNull(context.getIsolationScope().getTransientSessionDescriptor("SSC")); - assertSame(fodDescriptor, context.getIsolationScope().getTransientSessionDescriptor("FoD")); + assertNull(context.getIsolationScope().getTransientSessionDescriptor("SSC")); + assertSame(fodDescriptor, context.getIsolationScope().getTransientSessionDescriptor("FoD")); - context.getIsolationScope().clearTransientSessionDescriptors(); + context.getIsolationScope().clearTransientSessionDescriptors(); - assertTrue(context.getIsolationScope().getTransientSessionDescriptors().isEmpty()); + assertTrue(context.getIsolationScope().getTransientSessionDescriptors().isEmpty()); + } } @Test void transientSessionDescriptorConvenienceSetterIndexesByType() { - var context = new FcliExecutionContext(); - var descriptor = new DummySessionDescriptor("dummy"); + try (var context = new FcliExecutionContext()) { + var descriptor = new DummySessionDescriptor("dummy"); - context.getIsolationScope().setTransientSessionDescriptor(descriptor); + context.getIsolationScope().setTransientSessionDescriptor(descriptor); - assertSame(descriptor, context.getIsolationScope().getTransientSessionDescriptor("dummy")); + assertSame(descriptor, context.getIsolationScope().getTransientSessionDescriptor("dummy")); + } } @Test - void pushNewInheritsIsolationScopeButCreatesFreshActionState() { + void pushNewAlwaysCreatesAFreshContext() { FcliExecutionContextHolder.pushNew(); try { var parent = FcliExecutionContextHolder.current(); @@ -72,8 +73,10 @@ void pushNewInheritsIsolationScopeButCreatesFreshActionState() { FcliExecutionContextHolder.pushNew(); try { var child = FcliExecutionContextHolder.current(); - assertEquals("ssc|abc123", FcliExecutionContextHolder.getMcpRequestAuthScopeKey()); - assertSame(parent.getIsolationScope(), child.getIsolationScope()); + // pushNew always creates a completely new context, so isolation scope is NOT inherited + var childScopeKey = child.getIsolationScope().getMcpRequestAuthScopeKey(); + assertTrue(childScopeKey == null || childScopeKey.isEmpty()); + assertFalse(parent.getIsolationScope() == child.getIsolationScope()); assertTrue(child.getActionState().getGlobalActionValues().isEmpty()); } finally { FcliExecutionContextHolder.pop(); @@ -84,15 +87,11 @@ void pushNewInheritsIsolationScopeButCreatesFreshActionState() { } @Test - void childContextsCanChooseFreshOrSharedActionState() { - var parent = new FcliExecutionContext(); - var freshChild = parent.createChild(); - var sharedChild = parent.createChildWithSharedActionState(); - - assertSame(parent.getIsolationScope(), freshChild.getIsolationScope()); - assertSame(parent.getIsolationScope(), sharedChild.getIsolationScope()); - assertTrue(freshChild.getActionState().getGlobalActionValues().isEmpty()); - assertSame(parent.getActionState(), sharedChild.getActionState()); + void createChildInheritsIsolationScopeAndCreatesFreshActionState() { + try (var parent = new FcliExecutionContext(); var child = parent.createChild()) { + assertSame(parent.getIsolationScope(), child.getIsolationScope()); + assertTrue(child.getActionState().getGlobalActionValues().isEmpty()); + } } @Test diff --git a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java index fbbd37c1633..36382e6b0a3 100644 --- a/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixinTest.java @@ -56,27 +56,6 @@ void persistedLookupIsUsedIfNoTransientDescriptorExistsForType() { } } - @Test - void transientSessionDescriptorIsFoundInNestedParentContext() { - var supplier = new DummySessionDescriptorSupplier(); - var transientDescriptor = new DummySessionDescriptor("transient"); - FcliExecutionContextHolder.pushNew(); - try { - FcliExecutionContextHolder.current().getIsolationScope().setTransientSessionDescriptor(transientDescriptor); - FcliExecutionContextHolder.pushNew(); - try { - var result = supplier.getSessionDescriptor(); - - assertSame(transientDescriptor, result); - assertEquals(0, supplier.lookupCount); - } finally { - FcliExecutionContextHolder.pop(); - } - } finally { - FcliExecutionContextHolder.pop(); - } - } - private static final class DummySessionDescriptorSupplier extends AbstractSessionDescriptorSupplierMixin { private int lookupCount; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java index fb25d9882db..af235f893e1 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java @@ -18,6 +18,7 @@ import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.cli.util.IFcliExecutionContextManager; import com.fortify.cli.common.mcp.MCPExclude; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.util.OutputHelper.OutputType; @@ -29,7 +30,7 @@ @Command(name = OutputHelperMixins.Start.CMD_NAME) @MCPExclude @Slf4j -public class MCPServerStartDeprecatedCommand extends AbstractRunnableCommand { +public class MCPServerStartDeprecatedCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { @Unmatched private List delegatedArgs; @Override @@ -43,7 +44,6 @@ public Integer call() { .cmd(cmd) .stdoutOutputType(OutputType.show) .stderrOutputType(OutputType.show) - .createInvocationContext(true) .onFail(r -> {}) .build().create().execute(); if ( result.getExitCode() != 0 && StringUtils.isNotBlank(result.getErr()) ) { diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java index 46227094d34..e7798b8fe3c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java @@ -17,6 +17,7 @@ import java.util.List; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.cli.util.IFcliExecutionContextManager; import com.fortify.cli.common.cli.util.StdioHelper; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.concurrent.job.cli.mixin.AsyncJobManagerMixin; @@ -42,7 +43,7 @@ @Command(name = OutputHelperMixins.Start.CMD_NAME) @MCPExclude @Slf4j -public class RPCServerStartCommand extends AbstractRunnableCommand { +public class RPCServerStartCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { // Stream overrides for functional tests (RPCServerHelper) that run the server // in-process via reflective invocation, where System streams cannot be replaced. private static volatile InputStream inputOverride; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java index 24eae82ac46..d31ce8e5d95 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerRegistry.java @@ -15,6 +15,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.function.Supplier; import com.fortify.cli.common.action.helper.ActionLoaderHelper; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; @@ -22,6 +23,7 @@ import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliExecutionContext; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.cli.util.FcliIsolationScope; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.concurrent.job.CachingJobEventListener; @@ -99,7 +101,9 @@ public static Builder builder(AsyncJobManager asyncJobManager) { public static final class Builder { private final AsyncJobManager asyncJobManager; private final FcliIsolationScope sharedIsolationScope = new FcliIsolationScope(); - private final FcliExecutionContext sharedFunctionContext = new FcliExecutionContext(sharedIsolationScope, new FcliActionState()); + private final FcliActionState sharedFunctionActionState = new FcliActionState(); + private final Supplier sharedFunctionFrameSupplier = + () -> FcliExecutionContextHolder.push(new FcliExecutionContext(sharedIsolationScope, sharedFunctionActionState)); private final Map handlers = new LinkedHashMap<>(); private final Map importedFunctions = new LinkedHashMap<>(); @@ -123,7 +127,7 @@ public Builder importAction(String importFile) { for (var entry : action.getFunctions().entrySet()) { var function = entry.getValue(); if (!function.isExported()) { continue; } - var executor = new ActionFunctionExecutor(action, function, sharedFunctionContext); + var executor = new ActionFunctionExecutor(action, function, sharedFunctionFrameSupplier); importedFunctions.put(function.getKey(), executor); log.debug("Imported exported function for fn.call: {}", function.getKey()); } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java index 439ea293b9e..f69f4a46961 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java @@ -223,12 +223,9 @@ private RPCResponse executeMethod(RPCRequest request) { } try { - FcliExecutionContextHolder.push(new FcliExecutionContext(registry.getIsolationScope(), new FcliActionState())); JsonNode result; - try { + try (var frame = FcliExecutionContextHolder.push(new FcliExecutionContext(registry.getIsolationScope(), new FcliActionState()))) { result = handler.execute(request.params()); - } finally { - FcliExecutionContextHolder.pop(); } return RPCResponse.success(request.id(), result); } catch (RPCMethodException e) { diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/ActionFunctionsSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/ActionFunctionsSpec.groovy index 469f37347b1..4fca77a62cd 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/ActionFunctionsSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/ActionFunctionsSpec.groovy @@ -10,6 +10,8 @@ import spock.lang.Shared @Prefix("core.action.functions") class ActionFunctionsSpec extends FcliBaseSpec { @Shared @TestResource("runtime/actions/functions.yaml") String functionsActionPath + @Shared @TestResource("runtime/actions/run-fcli-shared-state-parent.yaml") String runFcliSharedStateParentActionPath + @Shared @TestResource("runtime/actions/run-fcli-shared-state-child.yaml") String runFcliSharedStateChildActionPath def "fn-call-spel"() { when: @@ -51,4 +53,20 @@ class ActionFunctionsSpec extends FcliBaseSpec { it.any { it.contains("internal-value") } } } + + def "run.fcli reuses parent action state"() { + when: + def result = Fcli.run([ + "action", "run", runFcliSharedStateParentActionPath, + "--progress=none", + "--on-unsigned=ignore", + "--on-invalid-version=ignore", + "--child-action-path", runFcliSharedStateChildActionPath + ]) + then: + verifyAll(result.stdout) { + it.any { it.contains("before=red") } + it.any { it.contains("after=blue") } + } + } } diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy index 03970070c15..bcda14943c4 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy @@ -23,6 +23,7 @@ import io.modelcontextprotocol.spec.McpSchema @Prefix("core.mcp-server.import") class MCPServerImportSpec extends FcliBaseSpec { @Shared @TestResource("runtime/actions/server-import-functions.yaml") String importActionPath + @Shared @TestResource("runtime/actions/server-global-vars.yaml") String globalVarsActionPath private McpSyncClient createMcpClient(String extraArgs = "") { def serverArgs = ["util", "mcp-server", "start", "--import", importActionPath] @@ -124,4 +125,32 @@ class MCPServerImportSpec extends FcliBaseSpec { cleanup: client?.closeGracefully() } + + def "imported functions share global vars across invocations"() { + given: + def client = createMcpClient("--import ${globalVarsActionPath}") + when: + def r1 = client.callTool(new McpSchema.CallToolRequest("fcli_fn_setAndGetGlobal", [key: "color", value: "red"])) + def t1 = asText(r1) + def r2 = client.callTool(new McpSchema.CallToolRequest("fcli_fn_setAndGetGlobal", [key: "color", value: "blue"])) + def t2 = asText(r2) + def r3 = client.callTool(new McpSchema.CallToolRequest("fcli_fn_getGlobal", [key: "color"])) + def t3 = asText(r3) + then: + !r1.isError() + !r2.isError() + !r3.isError() + t1.contains("old=,new=red") + t2.contains("old=red,new=blue") + t3.contains("blue") + cleanup: + client?.closeGracefully() + } + + private static String asText(McpSchema.CallToolResult result) { + return result.content() + .findAll { it instanceof McpSchema.TextContent } + .collect { ((McpSchema.TextContent) it).text() } + .join("") + } } diff --git a/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/run-fcli-shared-state-child.yaml b/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/run-fcli-shared-state-child.yaml new file mode 100644 index 00000000000..895d32badf9 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/run-fcli-shared-state-child.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: ftest +usage: + header: Ftest run.fcli child action state sharing + description: Child action for verifying run.fcli context/state sharing + +steps: + - out.write: + stdout: before=${global.color} + + - var.set: + global.color: blue diff --git a/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/run-fcli-shared-state-parent.yaml b/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/run-fcli-shared-state-parent.yaml new file mode 100644 index 00000000000..42b4ecc099d --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/resources/runtime/actions/run-fcli-shared-state-parent.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: ftest +usage: + header: Ftest run.fcli parent action state sharing + description: Parent action for verifying run.fcli context/state sharing + +cli.options: + childActionPath: + names: --child-action-path + description: Absolute path to the child action file + required: true + +steps: + - var.set: + global.color: red + + - run.fcli: + runChild: + cmd: action run "${cli.childActionPath}" --progress none --on-unsigned ignore --on-invalid-version ignore + + - out.write: + stdout: after=${global.color} From fe28931e6bee39fa714582cbdcab8c017d71e65a Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 11 May 2026 14:02:40 +0200 Subject: [PATCH 14/55] ftest: Fix functional test execution --- .../_common/MCPHttpServerTestHelper.groovy | 115 +++++++ .../cli/ftest/core/MCPServerHttpSpec.groovy | 282 ------------------ .../cli/ftest/fod/FoDMCPServerHttpSpec.groovy | 83 ++++++ .../cli/ftest/ssc/SSCMCPServerHttpSpec.groovy | 124 ++++++++ 4 files changed, 322 insertions(+), 282 deletions(-) create mode 100644 fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy delete mode 100644 fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerHttpSpec.groovy create mode 100644 fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy create mode 100644 fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy new file mode 100644 index 00000000000..c4f776f6f09 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy @@ -0,0 +1,115 @@ +package com.fortify.cli.ftest._common + +import java.net.ServerSocket +import java.net.Socket +import java.net.http.HttpRequest +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.TimeUnit + +import com.fasterxml.jackson.databind.ObjectMapper +import io.modelcontextprotocol.client.McpClient +import io.modelcontextprotocol.client.McpSyncClient +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper +import io.modelcontextprotocol.spec.McpSchema + +/** + * Shared utilities for functional tests that interact with the fcli HTTP MCP server. + */ +class MCPHttpServerTestHelper { + + static HttpClientHandle startHttpClient(HttpServerConfig config, String authHeaderName, String authHeaderValue) { + def process = startHttpServer(config) + def transport = HttpClientStreamableHttpTransport.builder("http://127.0.0.1:${config.port}") + .endpoint("/mcp") + .connectTimeout(Duration.ofSeconds(10)) + .jsonMapper(new JacksonMcpJsonMapper(new ObjectMapper())) + .customizeRequest({ HttpRequest.Builder builder -> builder.header(authHeaderName, authHeaderValue) }) + .build() + def client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(30)) + .initializationTimeout(Duration.ofSeconds(60)) + .build() + client.initialize() + return new HttpClientHandle(client, process) + } + + static Process startHttpServer(HttpServerConfig config) { + def cmd = Fcli.buildExternalCommand(["agent", "mcp", "start-http", "--config", config.path.toString()]) + def process = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .start() + waitForServerStartup(process, config.port) + return process + } + + static void waitForServerStartup(Process process, int port) { + def deadline = System.currentTimeMillis() + 30_000 + while ( System.currentTimeMillis() < deadline ) { + if ( !process.alive() ) { + throw new RuntimeException("HTTP MCP server exited before startup completed (exit code ${process.exitValue()})") + } + if ( isPortOpen(port) ) { + return + } + Thread.sleep(100) + } + process.destroyForcibly() + process.waitFor(5, TimeUnit.SECONDS) + throw new RuntimeException("HTTP MCP server did not start within 30 seconds on port ${port}") + } + + static boolean isPortOpen(int port) { + try { + new Socket("127.0.0.1", port).close() + return true + } catch ( IOException ignored ) { + return false + } + } + + static int getFreePort() { + new ServerSocket(0).withCloseable { it.localPort } + } + + static String escapeAuthHeaderValue(String value) { + return value.replace("\\", "\\\\").replace(";", "\\;").replace("=", "\\=") + } + + static String getText(McpSchema.CallToolResult result) { + return result.content().findAll { it instanceof McpSchema.TextContent } + .collect { ((McpSchema.TextContent)it).text() } + .join("") + } + + static final class HttpServerConfig { + final Path path + final int port + + HttpServerConfig(Path path, int port) { + this.path = path + this.port = port + } + } + + static final class HttpClientHandle implements Closeable { + final McpSyncClient client + final Process process + + HttpClientHandle(McpSyncClient client, Process process) { + this.client = client + this.process = process + } + + @Override + void close() throws IOException { + try { + client?.closeGracefully() + } finally { + process?.destroyForcibly() + process?.waitFor(5, TimeUnit.SECONDS) + } + } + } +} diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerHttpSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerHttpSpec.groovy deleted file mode 100644 index 6154d9a4beb..00000000000 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerHttpSpec.groovy +++ /dev/null @@ -1,282 +0,0 @@ -package com.fortify.cli.ftest.core - -import java.net.ServerSocket -import java.net.Socket -import java.net.http.HttpRequest -import java.nio.file.Files -import java.nio.file.Path -import java.time.Duration -import java.util.concurrent.TimeUnit - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fortify.cli.ftest._common.Fcli -import com.fortify.cli.ftest._common.spec.FcliBaseSpec -import com.fortify.cli.ftest._common.spec.Prefix -import com.fortify.cli.ftest._common.spec.TempDir -import com.fortify.cli.ftest._common.spec.TestResource - -import io.modelcontextprotocol.client.McpClient -import io.modelcontextprotocol.client.McpSyncClient -import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport -import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper -import io.modelcontextprotocol.spec.McpSchema -import spock.lang.IgnoreIf -import spock.lang.Requires -import spock.lang.Shared - -@IgnoreIf({ !sys["ft.fcli"] || sys["ft.fcli"] == "build" }) -@Prefix("core.mcp-server.http") -class MCPServerHttpSpec extends FcliBaseSpec { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() - - @Shared @TempDir("core/mcp-http") String tempDir - @Shared @TestResource("runtime/actions/server-import-functions.yaml") String commonImportActionPath - @Shared @TestResource("runtime/actions/server-import-http-ssc-functions.yaml") String sscImportActionPath - @Shared @TestResource("runtime/actions/server-import-http-fod-functions.yaml") String fodImportActionPath - - @Requires({ - System.getProperty('ft.ssc.url') && - (System.getProperty('ft.ssc.token') || - (System.getProperty('ft.ssc.user') && System.getProperty('ft.ssc.password'))) - }) - def "http mcp supports ssc auth-backed tools"() { - given: - def auth = createSscAuth() - def config = createSscConfig() - def handle = startHttpClient(config, "X-AUTH-SSC", auth.headerValue as String) - - when: - def toolNames = handle.client.listTools().tools().collect { it.name() } as Set - def productResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_sscRestCount", [:])) - def productText = getText(productResult) - def streamingResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_generateItems", [items: [0, 1, 2]])) - def streamingText = getText(streamingResult) - - then: - toolNames.containsAll(["fcli_fn_echo", "fcli_fn_generateItems", "fcli_fn_sscRestCount", "fcli_mcp_job"]) - productText.contains("SSC-REST-OK count=") - !productResult.isError() - streamingText.contains("item-0") - streamingText.contains("item-1") - streamingText.contains("item-2") - !streamingResult.isError() - - cleanup: - handle?.close() - auth?.cleanup?.call() - } - - @Requires({ - System.getProperty('ft.fod.url') && - System.getProperty('ft.fod.tenant') && - System.getProperty('ft.fod.user') && - System.getProperty('ft.fod.password') - }) - def "http mcp supports fod auth-backed tools"() { - given: - def config = createFoDConfig() - def handle = startHttpClient(config, "X-AUTH-FOD", createFoDAuthHeaderValue()) - - when: - def toolNames = handle.client.listTools().tools().collect { it.name() } as Set - def productResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_fodRestCount", [:])) - def productText = getText(productResult) - def streamingResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_generateItems", [items: [3, 4, 5]])) - def streamingText = getText(streamingResult) - - then: - toolNames.containsAll(["fcli_fn_echo", "fcli_fn_generateItems", "fcli_fn_fodRestCount", "fcli_mcp_job"]) - productText.contains("FOD-REST-OK count=") - !productResult.isError() - streamingText.contains("item-3") - streamingText.contains("item-4") - streamingText.contains("item-5") - !streamingResult.isError() - - cleanup: - handle?.close() - } - - private HttpServerConfig createSscConfig() { - def port = getFreePort() - def configPath = Path.of(tempDir, "mcp-http-ssc-${port}.yaml") - def scSastClientAuthToken = System.getProperty("ft.ssc.client-auth-token") - def config = new StringBuilder() - .append("port: ${port}\n") - .append("imports:\n") - .append(" - ${commonImportActionPath}\n") - .append(" - ${sscImportActionPath}\n") - .append("ssc:\n") - .append(" url: ${System.getProperty('ft.ssc.url')}\n") - .append(" connectTimeout: 30s\n") - .append(" socketTimeout: 10m\n") - .append(" insecureModeEnabled: false\n") - if ( scSastClientAuthToken ) { - config.append(" scSastClientAuthToken: ${scSastClientAuthToken}\n") - } - Files.writeString(configPath, config.toString()) - return new HttpServerConfig(configPath, port) - } - - private HttpServerConfig createFoDConfig() { - def port = getFreePort() - def configPath = Path.of(tempDir, "mcp-http-fod-${port}.yaml") - def config = """ - port: ${port} - imports: - - ${commonImportActionPath} - - ${fodImportActionPath} - fod: - url: ${System.getProperty('ft.fod.url')} - connectTimeout: 30s - socketTimeout: 10m - insecureModeEnabled: false - """.stripIndent() - Files.writeString(configPath, config) - return new HttpServerConfig(configPath, port) - } - - private Map createSscAuth() { - def configuredToken = System.getProperty("ft.ssc.token") - if ( configuredToken ) { - return [ - headerValue: createSscAuthHeaderValue(configuredToken), - cleanup: {} - ] - } - - def user = System.getProperty("ft.ssc.user") - def password = System.getProperty("ft.ssc.password") - def tokenName = "HttpMcpFtest-${System.currentTimeMillis()}" - def result = Fcli.run([ - "ssc", "ac", "create-token", tokenName, - "--expire-in=5m", - "--user=${user}", - "--password=${password}", - "-o", "json" - ]) - def tokenData = OBJECT_MAPPER.readTree(result.stdout.join("\n")) - def restToken = tokenData.get("restToken").asText() - return [ - headerValue: createSscAuthHeaderValue(restToken), - cleanup: { - Fcli.run([ - "ssc", "ac", "revoke-token", restToken, - "--user=${user}", - "--password=${password}" - ]) - } - ] - } - - private String createSscAuthHeaderValue(String restToken) { - def values = ["token=${escapeAuthHeaderValue(restToken)}"] - def scSastClientAuthToken = System.getProperty("ft.ssc.client-auth-token") - if ( scSastClientAuthToken ) { - values << "sc-sast-token=${escapeAuthHeaderValue(scSastClientAuthToken)}" - } - return values.join(";") - } - - private String createFoDAuthHeaderValue() { - return [ - "tenant=${escapeAuthHeaderValue(System.getProperty('ft.fod.tenant'))}", - "user=${escapeAuthHeaderValue(System.getProperty('ft.fod.user'))}", - "pat=${escapeAuthHeaderValue(System.getProperty('ft.fod.password'))}" - ].join(";") - } - - private HttpClientHandle startHttpClient(HttpServerConfig config, String authHeaderName, String authHeaderValue) { - def process = startHttpServer(config) - def transport = HttpClientStreamableHttpTransport.builder("http://127.0.0.1:${config.port}") - .endpoint("/mcp") - .connectTimeout(Duration.ofSeconds(10)) - .jsonMapper(new JacksonMcpJsonMapper(new ObjectMapper())) - .customizeRequest({ HttpRequest.Builder builder -> builder.header(authHeaderName, authHeaderValue) }) - .build() - def client = McpClient.sync(transport) - .requestTimeout(Duration.ofSeconds(30)) - .initializationTimeout(Duration.ofSeconds(60)) - .build() - client.initialize() - return new HttpClientHandle(client, process) - } - - private Process startHttpServer(HttpServerConfig config) { - def cmd = Fcli.buildExternalCommand(["agent", "mcp", "start-http", "--config", config.path.toString()]) - def process = new ProcessBuilder(cmd) - .redirectErrorStream(true) - .start() - waitForServerStartup(process, config.port) - return process - } - - private static void waitForServerStartup(Process process, int port) { - def deadline = System.currentTimeMillis() + 30_000 - while ( System.currentTimeMillis() < deadline ) { - if ( !process.alive() ) { - throw new RuntimeException("HTTP MCP server exited before startup completed (exit code ${process.exitValue()})") - } - if ( isPortOpen(port) ) { - return - } - Thread.sleep(100) - } - process.destroyForcibly() - process.waitFor(5, TimeUnit.SECONDS) - throw new RuntimeException("HTTP MCP server did not start within 30 seconds on port ${port}") - } - - private static boolean isPortOpen(int port) { - try { - new Socket("127.0.0.1", port).close() - return true - } catch ( IOException ignored ) { - return false - } - } - - private static int getFreePort() { - new ServerSocket(0).withCloseable { it.localPort } - } - - private static String escapeAuthHeaderValue(String value) { - return value.replace("\\", "\\\\").replace(";", "\\;").replace("=", "\\=") - } - - private static String getText(McpSchema.CallToolResult result) { - return result.content().findAll { it instanceof McpSchema.TextContent } - .collect { ((McpSchema.TextContent)it).text() } - .join("") - } - - private static final class HttpServerConfig { - private final Path path - private final int port - - private HttpServerConfig(Path path, int port) { - this.path = path - this.port = port - } - } - - private static final class HttpClientHandle implements Closeable { - private final McpSyncClient client - private final Process process - - private HttpClientHandle(McpSyncClient client, Process process) { - this.client = client - this.process = process - } - - @Override - void close() throws IOException { - try { - client?.closeGracefully() - } finally { - process?.destroyForcibly() - process?.waitFor(5, TimeUnit.SECONDS) - } - } - } -} \ No newline at end of file diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy new file mode 100644 index 00000000000..8e9e3988de7 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy @@ -0,0 +1,83 @@ +package com.fortify.cli.ftest.fod + +import java.nio.file.Files +import java.nio.file.Path + +import com.fortify.cli.ftest._common.MCPHttpServerTestHelper +import com.fortify.cli.ftest._common.MCPHttpServerTestHelper.HttpClientHandle +import com.fortify.cli.ftest._common.MCPHttpServerTestHelper.HttpServerConfig +import com.fortify.cli.ftest._common.spec.FcliBaseSpec +import com.fortify.cli.ftest._common.spec.Prefix +import com.fortify.cli.ftest._common.spec.TempDir +import com.fortify.cli.ftest._common.spec.TestResource + +import io.modelcontextprotocol.spec.McpSchema +import spock.lang.IgnoreIf +import spock.lang.Requires +import spock.lang.Shared + +@IgnoreIf({ !sys["ft.fcli"] || sys["ft.fcli"] == "build" }) +@Prefix("fod.mcp-server.http") +class FoDMCPServerHttpSpec extends FcliBaseSpec { + + @Shared @TempDir("fod/mcp-http") String tempDir + @Shared @TestResource("runtime/actions/server-import-functions.yaml") String commonImportActionPath + @Shared @TestResource("runtime/actions/server-import-http-fod-functions.yaml") String fodImportActionPath + + @Requires({ + System.getProperty('ft.fod.url') && + System.getProperty('ft.fod.tenant') && + System.getProperty('ft.fod.user') && + System.getProperty('ft.fod.password') + }) + def "http mcp supports fod auth-backed tools"() { + given: + def config = createFoDConfig() + def handle = MCPHttpServerTestHelper.startHttpClient(config, "X-AUTH-FOD", createFoDAuthHeaderValue()) + + when: + def toolNames = handle.client.listTools().tools().collect { it.name() } as Set + def productResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_fodRestCount", [:])) + def productText = MCPHttpServerTestHelper.getText(productResult) + def streamingResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_generateItems", [items: [3, 4, 5]])) + def streamingText = MCPHttpServerTestHelper.getText(streamingResult) + + then: + toolNames.containsAll(["fcli_fn_echo", "fcli_fn_generateItems", "fcli_fn_fodRestCount", "fcli_mcp_job"]) + productText.contains("FOD-REST-OK count=") + !productResult.isError() + streamingText.contains("item-3") + streamingText.contains("item-4") + streamingText.contains("item-5") + !streamingResult.isError() + + cleanup: + handle?.close() + } + + private HttpServerConfig createFoDConfig() { + def port = MCPHttpServerTestHelper.getFreePort() + def configPath = Path.of(tempDir, "mcp-http-fod-${port}.yaml") + def config = """ + port: ${port} + imports: + - ${commonImportActionPath} + - ${fodImportActionPath} + fod: + url: ${System.getProperty('ft.fod.url')} + connectTimeout: 30s + socketTimeout: 10m + insecureModeEnabled: false + """.stripIndent() + Files.writeString(configPath, config) + return new HttpServerConfig(configPath, port) + } + + private String createFoDAuthHeaderValue() { + return [ + "tenant=${MCPHttpServerTestHelper.escapeAuthHeaderValue(System.getProperty('ft.fod.tenant'))}", + "user=${MCPHttpServerTestHelper.escapeAuthHeaderValue(System.getProperty('ft.fod.user'))}", + "pat=${MCPHttpServerTestHelper.escapeAuthHeaderValue(System.getProperty('ft.fod.password'))}" + ].join(";") + } +} diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy new file mode 100644 index 00000000000..8bb0ac3fbcb --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy @@ -0,0 +1,124 @@ +package com.fortify.cli.ftest.ssc + +import java.nio.file.Files +import java.nio.file.Path + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fortify.cli.ftest._common.Fcli +import com.fortify.cli.ftest._common.MCPHttpServerTestHelper +import com.fortify.cli.ftest._common.MCPHttpServerTestHelper.HttpClientHandle +import com.fortify.cli.ftest._common.MCPHttpServerTestHelper.HttpServerConfig +import com.fortify.cli.ftest._common.spec.FcliBaseSpec +import com.fortify.cli.ftest._common.spec.Prefix +import com.fortify.cli.ftest._common.spec.TempDir +import com.fortify.cli.ftest._common.spec.TestResource + +import io.modelcontextprotocol.spec.McpSchema +import spock.lang.IgnoreIf +import spock.lang.Requires +import spock.lang.Shared + +@IgnoreIf({ !sys["ft.fcli"] || sys["ft.fcli"] == "build" }) +@Prefix("ssc.mcp-server.http") +class SSCMCPServerHttpSpec extends FcliBaseSpec { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + + @Shared @TempDir("ssc/mcp-http") String tempDir + @Shared @TestResource("runtime/actions/server-import-functions.yaml") String commonImportActionPath + @Shared @TestResource("runtime/actions/server-import-http-ssc-functions.yaml") String sscImportActionPath + + @Requires({ + System.getProperty('ft.ssc.url') && + (System.getProperty('ft.ssc.token') || + (System.getProperty('ft.ssc.user') && System.getProperty('ft.ssc.password'))) + }) + def "http mcp supports ssc auth-backed tools"() { + given: + def auth = createSscAuth() + def config = createSscConfig() + def handle = MCPHttpServerTestHelper.startHttpClient(config, "X-AUTH-SSC", auth.headerValue as String) + + when: + def toolNames = handle.client.listTools().tools().collect { it.name() } as Set + def productResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_sscRestCount", [:])) + def productText = MCPHttpServerTestHelper.getText(productResult) + def streamingResult = handle.client.callTool(new McpSchema.CallToolRequest("fcli_fn_generateItems", [items: [0, 1, 2]])) + def streamingText = MCPHttpServerTestHelper.getText(streamingResult) + + then: + toolNames.containsAll(["fcli_fn_echo", "fcli_fn_generateItems", "fcli_fn_sscRestCount", "fcli_mcp_job"]) + productText.contains("SSC-REST-OK count=") + !productResult.isError() + streamingText.contains("item-0") + streamingText.contains("item-1") + streamingText.contains("item-2") + !streamingResult.isError() + + cleanup: + handle?.close() + auth?.cleanup?.call() + } + + private HttpServerConfig createSscConfig() { + def port = MCPHttpServerTestHelper.getFreePort() + def configPath = Path.of(tempDir, "mcp-http-ssc-${port}.yaml") + def scSastClientAuthToken = System.getProperty("ft.ssc.client-auth-token") + def config = new StringBuilder() + .append("port: ${port}\n") + .append("imports:\n") + .append(" - ${commonImportActionPath}\n") + .append(" - ${sscImportActionPath}\n") + .append("ssc:\n") + .append(" url: ${System.getProperty('ft.ssc.url')}\n") + .append(" connectTimeout: 30s\n") + .append(" socketTimeout: 10m\n") + .append(" insecureModeEnabled: false\n") + if ( scSastClientAuthToken ) { + config.append(" scSastClientAuthToken: ${scSastClientAuthToken}\n") + } + Files.writeString(configPath, config.toString()) + return new HttpServerConfig(configPath, port) + } + + private Map createSscAuth() { + def configuredToken = System.getProperty("ft.ssc.token") + if ( configuredToken ) { + return [ + headerValue: createSscAuthHeaderValue(configuredToken), + cleanup: {} + ] + } + + def user = System.getProperty("ft.ssc.user") + def password = System.getProperty("ft.ssc.password") + def tokenName = "HttpMcpFtest-${System.currentTimeMillis()}" + def result = Fcli.run([ + "ssc", "ac", "create-token", tokenName, + "--expire-in=5m", + "--user=${user}", + "--password=${password}", + "-o", "json" + ]) + def tokenData = OBJECT_MAPPER.readTree(result.stdout.join("\n")) + def restToken = tokenData.get("restToken").asText() + return [ + headerValue: createSscAuthHeaderValue(restToken), + cleanup: { + Fcli.run([ + "ssc", "ac", "revoke-token", restToken, + "--user=${user}", + "--password=${password}" + ]) + } + ] + } + + private String createSscAuthHeaderValue(String restToken) { + def values = ["token=${MCPHttpServerTestHelper.escapeAuthHeaderValue(restToken)}"] + def scSastClientAuthToken = System.getProperty("ft.ssc.client-auth-token") + if ( scSastClientAuthToken ) { + values << "sc-sast-token=${MCPHttpServerTestHelper.escapeAuthHeaderValue(scSastClientAuthToken)}" + } + return values.join(";") + } +} From d6f09f01b0609695e43faefb8d6b03593f325342 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 11 May 2026 16:31:29 +0200 Subject: [PATCH 15/55] chore: Fixes & improvements --- .../app/runner/DefaultFortifyCLIRunner.java | 83 ++++++++++++------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/DefaultFortifyCLIRunner.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/DefaultFortifyCLIRunner.java index 503f0a9fcf7..3a1bce0bb12 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/DefaultFortifyCLIRunner.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/DefaultFortifyCLIRunner.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.function.Supplier; import java.util.stream.Stream; import com.fortify.cli.app._main.cli.cmd.FCLIRootCommands; @@ -22,6 +23,7 @@ import com.fortify.cli.app.runner.util.FortifyCLIDynamicInitializer; import com.fortify.cli.app.runner.util.FortifyCLIStaticInitializer; import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.cli.util.FcliExecutionStrategyFactory; import com.fortify.cli.common.cli.util.FcliWrappedHelpExclude; import com.fortify.cli.common.cli.util.StdioHelper; @@ -43,6 +45,35 @@ public final class DefaultFortifyCLIRunner { // TODO See https://github.com/remkop/picocli/issues/2066 //@Getter(value = AccessLevel.PRIVATE, lazy = true) //private final CommandLine commandLine = createCommandLine(); + + public static final int run(String... args) { + StdioHelper.install(); + try { + return prepareExecution(normalizeArgs(args)).get(); + } finally { + StdioHelper.uninstall(); + } + } + + /** + * Run all initializers and build the configured {@link CommandLine} object within a + * short-lived bootstrap execution context, then return a supplier that executes the + * command. The bootstrap context gives initializers (e.g. trust-store loading) access + * to {@code FcliExecutionContextHolder.current()} without requiring any special-casing + * in the helper code, while ensuring the stack is empty again before + * {@code cl.execute()} is called so that {@link com.fortify.cli.common.cli.util.FcliExecutionStrategy} + * manages the per-command context exactly as normal. + */ + private static Supplier prepareExecution(NormalizedArgs normalizedArgs) { + try (var bootstrapContext = FcliExecutionContextHolder.pushNew()) { + FortifyCLIDynamicInitializer.getInstance().initialize(normalizedArgs.args()); + //CommandLine cl = getCommandLine(); // TODO See https://github.com/remkop/picocli/issues/2066 + CommandLine cl = createCommandLine(normalizedArgs.isWrapped()); + FcliExecutionStrategyFactory.configureCommandLine(cl); + var resolvedArgs = normalizedArgs.args(); + return () -> { cl.clearExecutionResults(); return cl.execute(resolvedArgs); }; + } + } private static final CommandLine createCommandLine(boolean useWrapperHelp) { FortifyCLIStaticInitializer.getInstance().initialize(); @@ -60,36 +91,30 @@ private static final CommandLine createCommandLine(boolean useWrapperHelp) { return cl; } - public static final int run(String... args) { - StdioHelper.install(); - try { - // If first arg is 'fcli', remove it. This allows for passing 'fcli' command name - // to scratch Docker image, for consistency with non-scratch/shell-based images. - if ( args.length>0 && "fcli".equalsIgnoreCase(args[0]) ) { - args = Arrays.copyOfRange(args, 1, args.length); - } - - // Check for -Xwrapped option and remove it from args - boolean isWrapped = Arrays.stream(args).anyMatch("-Xwrapped"::equals); - if ( isWrapped ) { - args = Arrays.stream(args).filter(arg -> !"-Xwrapped".equals(arg)).toArray(String[]::new); - } - - // Replace --fcli-help with --help for wrapper compatibility - args = Arrays.stream(args) - .map(arg -> "--fcli-help".equals(arg) ? "--help" : arg) - .toArray(String[]::new); - - String[] resolvedArgs = FcliVariableHelper.resolveVariables(args); - FortifyCLIDynamicInitializer.getInstance().initialize(resolvedArgs); - //CommandLine cl = getCommandLine(); // TODO See https://github.com/remkop/picocli/issues/2066 - CommandLine cl = createCommandLine(isWrapped); - FcliExecutionStrategyFactory.configureCommandLine(cl); - cl.clearExecutionResults(); - return cl.execute(resolvedArgs); - } finally { - StdioHelper.uninstall(); + /** Holds normalized (pre-processed and variable-resolved) arguments together with wrapper detection. */ + private record NormalizedArgs(String[] args, boolean isWrapped) {} + + /** + * Normalize raw command-line arguments: strip an optional leading {@code fcli} token, + * detect and remove {@code -Xwrapped}, translate {@code --fcli-help} to {@code --help}, + * and resolve any fcli variable references. + */ + private static NormalizedArgs normalizeArgs(String... args) { + // If first arg is 'fcli', remove it. This allows for passing 'fcli' command name + // to scratch Docker image, for consistency with non-scratch/shell-based images. + if ( args.length>0 && "fcli".equalsIgnoreCase(args[0]) ) { + args = Arrays.copyOfRange(args, 1, args.length); + } + // Check for -Xwrapped option and remove it from args + boolean isWrapped = Arrays.stream(args).anyMatch("-Xwrapped"::equals); + if ( isWrapped ) { + args = Arrays.stream(args).filter(arg -> !"-Xwrapped".equals(arg)).toArray(String[]::new); } + // Replace --fcli-help with --help for wrapper compatibility + args = Arrays.stream(args) + .map(arg -> "--fcli-help".equals(arg) ? "--help" : arg) + .toArray(String[]::new); + return new NormalizedArgs(FcliVariableHelper.resolveVariables(args), isWrapped); } private static abstract class AbstractFcliHelp extends CommandLine.Help { From 0c4fb1c22265c79a7aa8f99b01b4f1bdc90b4fe1 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 11 May 2026 19:37:24 +0200 Subject: [PATCH 16/55] ftest: Various fixes --- .../fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy | 2 +- .../com/fortify/cli/ftest/ssc/SSCAlertDefinitionSpec.groovy | 2 +- .../com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy index c4f776f6f09..296e061d06d 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy @@ -47,7 +47,7 @@ class MCPHttpServerTestHelper { static void waitForServerStartup(Process process, int port) { def deadline = System.currentTimeMillis() + 30_000 while ( System.currentTimeMillis() < deadline ) { - if ( !process.alive() ) { + if ( !process.isAlive() ) { throw new RuntimeException("HTTP MCP server exited before startup completed (exit code ${process.exitValue()})") } if ( isPortOpen(port) ) { diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCAlertDefinitionSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCAlertDefinitionSpec.groovy index 956b51168a0..d12903a5656 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCAlertDefinitionSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCAlertDefinitionSpec.groovy @@ -50,7 +50,7 @@ class SSCAlertDefinitionSpec extends FcliBaseSpec { def "get.byId"() { - def args = "ssc alert getdef ::alertdefinitions::get(0).id" + def args = "ssc alert get-definition ::alertdefinitions::get(0).id" when: if(!definitionsExist) {return;} def result = Fcli.run(args) diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy index 8bb0ac3fbcb..cc7d4db74d5 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy @@ -93,8 +93,9 @@ class SSCMCPServerHttpSpec extends FcliBaseSpec { def password = System.getProperty("ft.ssc.password") def tokenName = "HttpMcpFtest-${System.currentTimeMillis()}" def result = Fcli.run([ - "ssc", "ac", "create-token", tokenName, + "ssc", "ac", "create-token", "UnifiedLoginToken", "--expire-in=5m", + "--description=${tokenName}", "--user=${user}", "--password=${password}", "-o", "json" From 88111e97251d808dd933ae7672512d715fbda3eb Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 13 May 2026 13:43:27 +0200 Subject: [PATCH 17/55] chore: Refactor FoD attribute handling, remove static caches --- .../scan/helper/FoDScanDescriptor.java | 6 +- .../fod/app/cli/cmd/FoDAppUpdateCommand.java | 4 +- .../fod/app/helper/FoDAppCreateRequest.java | 4 +- .../cli/fod/app/helper/FoDAppDescriptor.java | 6 +- .../cli/cmd/FoDAttributeCreateCommand.java | 4 +- .../cli/cmd/FoDAttributeDeleteCommand.java | 6 +- .../cli/cmd/FoDAttributeUpdateCommand.java | 10 +- .../cli/mixin/FoDAttributeResolverMixin.java | 19 +- ... => FoDAttributeDefinitionDescriptor.java} | 12 +- .../helper/FoDAttributeDefinitionHelper.java | 276 ++++++++++++++++++ .../attribute/helper/FoDAttributeHelper.java | 248 ---------------- .../helper/FoDAttributeValueDescriptor.java | 35 +++ .../issue/cli/cmd/FoDIssueUpdateCommand.java | 14 +- .../issue/helper/FoDIssueAttributeHelper.java | 144 +++++++++ .../cli/fod/issue/helper/FoDIssueHelper.java | 193 +----------- .../cli/cmd/FoDMicroserviceCreateCommand.java | 4 +- .../cli/cmd/FoDMicroserviceUpdateCommand.java | 19 +- .../helper/FoDMicroserviceDescriptor.java | 6 +- .../helper/FoDMicroserviceHelper.java | 4 +- .../cli/cmd/FoDReleaseCreateCommand.java | 4 +- .../cli/cmd/FoDReleaseUpdateCommand.java | 4 +- .../release/helper/FoDReleaseDescriptor.java | 6 +- 22 files changed, 521 insertions(+), 507 deletions(-) rename fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/{FoDAttributeDescriptor.java => FoDAttributeDefinitionDescriptor.java} (73%) create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java delete mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java index e3e1258c2e1..c4b96737522 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java @@ -22,7 +22,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -40,7 +40,7 @@ public class FoDScanDescriptor extends JsonNodeHolder { private String microserviceName; private String analysisStatusType; private String status; - private ArrayList attributes; + private ArrayList attributes; @JsonIgnore public String getReleaseAndScanId() { @@ -57,7 +57,7 @@ public Map attributesAsMap() { return Collections.emptyMap(); } Map attrMap = new HashMap<>(); - for (FoDAttributeDescriptor attr : attributes) { + for (FoDAttributeValueDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return Collections.unmodifiableMap(attrMap); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java index aecec5e3595..67ee8439d2f 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java @@ -32,7 +32,7 @@ import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.app.helper.FoDAppUpdateRequest; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -60,7 +60,7 @@ public class FoDAppUpdateCommand extends AbstractFoDJsonNodeOutputCommand implem public JsonNode getJsonNode(UnirestInstance unirest) { FoDAppDescriptor appDescriptor = FoDAppHelper.getAppDescriptor(unirest, appResolver.getAppNameOrId(), true); FoDCriticalityTypeOptions.FoDCriticalityType appCriticalityNew = criticalityTypeUpdate.getCriticalityType(); - JsonNode jsonAttrs = FoDAttributeHelper.getAttributesNodeForUpdate(unirest, FoDEnums.AttributeTypes.Application, + JsonNode jsonAttrs = new FoDAttributeDefinitionHelper(unirest).buildAttributesNodeForUpdate(FoDEnums.AttributeTypes.Application, appDescriptor.getAttributes(), appAttrsUpdate.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs()); String appEmailListNew = FoDAppHelper.getEmailList(notificationsUpdate); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java index 32af2af3acc..96c0432bb9d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java @@ -34,7 +34,7 @@ import com.fortify.cli.fod.app.cli.mixin.FoDAppTypeOptions.FoDAppType; import com.fortify.cli.fod.app.cli.mixin.FoDCriticalityTypeOptions.FoDCriticalityType; import com.fortify.cli.fod.app.cli.mixin.FoDSdlcStatusTypeOptions.FoDSdlcStatusType; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.release.helper.FoDQualifiedReleaseNameDescriptor; import kong.unirest.UnirestInstance; @@ -111,7 +111,7 @@ public FoDAppCreateRequestBuilder appType(FoDAppType appType) { } public FoDAppCreateRequestBuilder autoAttributes(UnirestInstance unirest, Map attributes, boolean autoRequiredAttrs) { - return attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.All, attributes, autoRequiredAttrs)); + return attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.All, attributes, autoRequiredAttrs)); } public FoDAppCreateRequestBuilder businessCriticality(FoDCriticalityType businessCriticalityType) { diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java index 1d3d19b3f87..3c5ea585143 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java @@ -18,7 +18,7 @@ import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -31,13 +31,13 @@ public class FoDAppDescriptor extends JsonNodeHolder { private String applicationName; private String applicationDescription; private String businessCriticalityType; - private ArrayList attributes; + private ArrayList attributes; private String emailList; private boolean hasMicroservices; public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeDescriptor attr : attributes) { + for (FoDAttributeValueDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java index d4bf3b50b25..07325444a41 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java @@ -22,7 +22,7 @@ import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeOptionCandidates; import com.fortify.cli.fod.attribute.helper.FoDAttributeCreateRequest; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.attribute.helper.FoDPicklistSortedValue; import kong.unirest.UnirestInstance; @@ -70,7 +70,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { .isRestricted(isRestricted) .picklistValues(getPicklistValues()) .build(); - return FoDAttributeHelper.createAttribute(unirest, attributeCreateRequest).asJsonNode(); + return new FoDAttributeDefinitionHelper(unirest).createDefinition(attributeCreateRequest).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java index 9c1fa7940b0..7a049c96488 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java @@ -18,8 +18,8 @@ import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod._common.rest.FoDUrls; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeResolverMixin; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -33,7 +33,7 @@ public class FoDAttributeDeleteCommand extends AbstractFoDJsonNodeOutputCommand @Override public JsonNode getJsonNode (UnirestInstance unirest){ - FoDAttributeDescriptor attrDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attributeResolver.getAttributeId(), true); + FoDAttributeDefinitionDescriptor attrDescriptor = new FoDAttributeDefinitionHelper(unirest).getDefinition(attributeResolver.getAttributeId(), true); unirest.delete(FoDUrls.ATTRIBUTE) .routeParam("attributeId", String.valueOf(attrDescriptor.getId())) .asObject(JsonNode.class).getBody(); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java index 319bdd73ccc..6d800fee621 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java @@ -15,13 +15,12 @@ import java.util.List; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeResolverMixin; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.attribute.helper.FoDAttributeUpdateRequest; import kong.unirest.UnirestInstance; @@ -35,7 +34,6 @@ public class FoDAttributeUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.Update outputHelper; @Mixin private FoDAttributeResolverMixin.PositionalParameter attributeResolver; - private final ObjectMapper objectMapper = new ObjectMapper(); @Option(names = {"--required"}) private Boolean isRequired; @@ -53,7 +51,7 @@ public class FoDAttributeUpdateCommand extends AbstractFoDJsonNodeOutputCommand public JsonNode getJsonNode(UnirestInstance unirest) { // current values of attribute being updated - FoDAttributeDescriptor attrDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attributeResolver.getAttributeId(), true); + FoDAttributeDefinitionDescriptor attrDescriptor = new FoDAttributeDefinitionHelper(unirest).getDefinition(attributeResolver.getAttributeId(), true); // build request object FoDAttributeUpdateRequest request = FoDAttributeUpdateRequest.builder() @@ -63,7 +61,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { .picklistValues(picklistValues) .build(); - return FoDAttributeHelper.updateAttribute(unirest, String.valueOf(attrDescriptor.getId()), request).asJsonNode(); + return new FoDAttributeDefinitionHelper(unirest).updateDefinition(String.valueOf(attrDescriptor.getId()), request).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java index b1df7ae7aa6..7aa9fc8da74 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java @@ -17,8 +17,8 @@ import java.util.stream.Stream; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -30,29 +30,30 @@ public class FoDAttributeResolverMixin { public static abstract class AbstractFoDAttributeResolverMixin { public abstract String getAttributeId(); - public FoDAttributeDescriptor getAttributeDescriptor(UnirestInstance unirest) { - return FoDAttributeHelper.getAttributeDescriptor(unirest, getAttributeId(), true); + public FoDAttributeDefinitionDescriptor getAttributeDescriptor(UnirestInstance unirest) { + return new FoDAttributeDefinitionHelper(unirest).getDefinition(getAttributeId(), true); } } public static abstract class AbstractFoDMultiAttributeResolverMixin { public abstract String[] getAttributeIds(); - public FoDAttributeDescriptor[] getAttributeDescriptors(UnirestInstance unirest) { + public FoDAttributeDefinitionDescriptor[] getAttributeDescriptors(UnirestInstance unirest) { + var helper = new FoDAttributeDefinitionHelper(unirest); return Stream.of(getAttributeIds()) - .map(id -> FoDAttributeHelper.getAttributeDescriptor(unirest, id, true)) - .toArray(FoDAttributeDescriptor[]::new); + .map(id -> helper.getDefinition(id, true)) + .toArray(FoDAttributeDefinitionDescriptor[]::new); } public Collection getAttributeDescriptorJsonNodes(UnirestInstance unirest) { return Stream.of(getAttributeDescriptors(unirest)) - .map(FoDAttributeDescriptor::asJsonNode) + .map(FoDAttributeDefinitionDescriptor::asJsonNode) .collect(Collectors.toList()); } public Integer[] getAttributeIds(UnirestInstance unirest) { return Stream.of(getAttributeDescriptors(unirest)) - .map(FoDAttributeDescriptor::getId) + .map(FoDAttributeDefinitionDescriptor::getId) .toArray(Integer[]::new); } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java similarity index 73% rename from fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java rename to fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java index 450140c0b96..1f20c24319d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java @@ -22,10 +22,15 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +/** + * Describes an FoD attribute definition — the schema record returned by the /api/v3/attributes + * endpoint. Contains metadata (type, data type, picklist values, required/restricted flags) but + * no entity-specific value. For entity attribute values see {@link FoDAttributeValueDescriptor}. + */ @Data @EqualsAndHashCode(callSuper = true) -@Reflectable @NoArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) // Fix for FoD 26.2+ where the API returns additional fields that are not mapped to this class (e.g. "isMultiSelect") -public class FoDAttributeDescriptor extends JsonNodeHolder { +@Reflectable @NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class FoDAttributeDefinitionDescriptor extends JsonNodeHolder { private Integer id; private String name; private Integer attributeTypeId; @@ -35,6 +40,5 @@ public class FoDAttributeDescriptor extends JsonNodeHolder { private Boolean isRequired; private Boolean isRestricted; private ArrayList picklistValues; - private String value; private String defaultValue; } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java new file mode 100644 index 00000000000..c0ec7164fa7 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java @@ -0,0 +1,276 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.attribute.helper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.rest.unirest.HttpHeader; +import com.fortify.cli.fod._common.rest.FoDUrls; +import com.fortify.cli.fod._common.rest.helper.FoDDataHelper; +import com.fortify.cli.fod._common.util.FoDEnums; + +import kong.unirest.UnirestInstance; +import lombok.Getter; + +/** + * Instance-based helper for FoD attribute definition operations. Lazily loads all attribute + * definitions on first use and caches them for the lifetime of this instance. Intended to be + * instantiated once per command execution; never stored statically. + */ +public class FoDAttributeDefinitionHelper { + private static final Logger LOG = LoggerFactory.getLogger(FoDAttributeDefinitionHelper.class); + private final UnirestInstance unirest; + + @Getter(lazy = true) + private final List allDefinitions = loadAllDefinitions(); + + public FoDAttributeDefinitionHelper(UnirestInstance unirest) { + this.unirest = unirest; + } + + private List loadAllDefinitions() { + var body = unirest.get(FoDUrls.ATTRIBUTES).asObject(ObjectNode.class).getBody(); + var items = body.get("items"); + if (items == null || !items.isArray()) { return Collections.emptyList(); } + List result = new ArrayList<>(); + for (var item : items) { + var def = JsonHelper.treeToValue(item, FoDAttributeDefinitionDescriptor.class); + if (def != null) { result.add(def); } + } + return Collections.unmodifiableList(result); + } + + /** + * Looks up an attribute definition by name or numeric id, searching the lazy-loaded list. + */ + public FoDAttributeDefinitionDescriptor getDefinition(String nameOrId, boolean failIfNotFound) { + if (nameOrId == null) { + if (failIfNotFound) { throw new FcliSimpleException("No attribute found for name or id: null"); } + return null; + } + var definitions = getAllDefinitions(); + FoDAttributeDefinitionDescriptor found; + try { + int id = Integer.parseInt(nameOrId); + found = definitions.stream().filter(d -> Objects.equals(d.getId(), id)).findFirst().orElse(null); + } catch (NumberFormatException nfe) { + String trimmed = nameOrId.trim(); + found = definitions.stream().filter(d -> trimmed.equalsIgnoreCase(d.getName())).findFirst().orElse(null); + } + if (found == null && failIfNotFound) { + throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); + } + return found; + } + + /** + * Returns a map of required-attribute names to their default values, filtered to the given type. + */ + public Map getRequiredDefaultValues(FoDEnums.AttributeTypes attrType) { + Map result = new HashMap<>(); + for (var def : getAllDefinitions()) { + if (def.getIsRequired() && (attrType.getValue() == 0 || Objects.equals(def.getAttributeTypeId(), attrType.getValue()))) { + var defaultValue = getDefaultValue(def); + if (defaultValue != null) { result.put(def.getName(), defaultValue); } + } + } + return result; + } + + /** + * Builds an attribute ArrayNode for create operations. Resolves names to ids, filters by + * attrType, and optionally adds defaults for any required attributes not already specified. + */ + public JsonNode buildAttributesNode(FoDEnums.AttributeTypes attrType, Map attributesMap, boolean autoReqdAttributes) { + var effectiveMap = buildEffectiveAttributeUpdates(attrType, null, attributesMap, autoReqdAttributes); + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + for (var entry : effectiveMap.entrySet()) { + var def = getDefinition(entry.getKey(), true); + if (attrType.getValue() == 0 || Objects.equals(def.getAttributeTypeId(), attrType.getValue())) { + ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); + attrObj.put("id", def.getId()); + attrObj.put("value", entry.getValue()); + attrArray.add(attrObj); + } else { + LOG.debug("Skipping attribute '{}' as it is not a {} attribute", def.getName(), attrType); + } + } + return attrArray; + } + + /** + * Builds an attribute ArrayNode for update operations. Merges current entity attribute values + * with user-supplied updates, optionally filling in defaults for required attributes. + */ + public JsonNode buildAttributesNodeForUpdate(FoDEnums.AttributeTypes attrType, + ArrayList currentAttributes, Map userSuppliedUpdates, + boolean autoReqdAttributes) { + var effectiveUpdates = buildEffectiveAttributeUpdates(attrType, currentAttributes, userSuppliedUpdates, autoReqdAttributes); + return effectiveUpdates.isEmpty() + ? attributeValuesToNode(currentAttributes) + : mergeAttributesNode(currentAttributes, effectiveUpdates); + } + + /** + * Merges current entity attribute values with a user-supplied name→value map. Resolves attribute + * names to IDs. Attributes already on the entity are updated; new attributes are appended. + */ + public JsonNode mergeAttributesNode(ArrayList current, Map updates) { + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + if (updates == null || updates.isEmpty()) { return attrArray; } + + Map updatesWithId = new HashMap<>(); + for (var entry : updates.entrySet()) { + var def = getDefinition(entry.getKey(), true); + updatesWithId.put(def.getId(), entry.getValue()); + } + + Set processedIds = new HashSet<>(); + if (current != null) { + for (var attr : current) { + int id = attr.getId(); + ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); + attrObj.put("id", id); + attrObj.put("value", updatesWithId.getOrDefault(id, attr.getValue())); + attrArray.add(attrObj); + processedIds.add(id); + } + } + for (var entry : updatesWithId.entrySet()) { + if (!processedIds.contains(entry.getKey())) { + ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); + attrObj.put("id", entry.getKey()); + attrObj.put("value", entry.getValue()); + attrArray.add(attrObj); + } + } + return attrArray; + } + + /** + * Pure serialization helper: converts a list of entity attribute values to an ArrayNode of + * {id, value} objects without any network calls. Useful when no updates are needed. + */ + public static JsonNode attributeValuesToNode(ArrayList values) { + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + if (values == null || values.isEmpty()) { return attrArray; } + for (var attr : values) { + ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); + attrObj.put("id", attr.getId()); + attrObj.put("value", attr.getValue()); + attrArray.add(attrObj); + } + return attrArray; + } + + /** + * Creates a new attribute definition. Returns the freshly fetched definition after creation. + */ + public FoDAttributeDefinitionDescriptor createDefinition(FoDAttributeCreateRequest request) { + var response = unirest.post(FoDUrls.ATTRIBUTES) + .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") + .body(request) + .asObject(JsonNode.class) + .getBody(); + if (response.has("success") && response.get("success").asBoolean()) { + if (!response.has("attributeId")) { + throw new FcliSimpleException("Response missing attributeId: " + response.toString()); + } + return fetchFromApi(response.get("attributeId").asText(), true); + } else { + throw new FcliSimpleException("Failed to create attribute: " + response.toString()); + } + } + + /** + * Updates an existing attribute definition. Returns the freshly fetched definition after update. + */ + public FoDAttributeDefinitionDescriptor updateDefinition(String attributeId, FoDAttributeUpdateRequest request) { + var response = unirest.put(FoDUrls.ATTRIBUTE) + .routeParam("attributeId", attributeId) + .body(request) + .asObject(JsonNode.class) + .getBody(); + if (response.has("success") && response.get("success").asBoolean()) { + return fetchFromApi(attributeId, true); + } else { + throw new FcliSimpleException("Failed to update attribute: " + response.toString()); + } + } + + /** + * Fetches a single attribute definition directly from the API, bypassing the lazy-loaded cache. + * Used after create/update operations where the cached list may be stale. + */ + private FoDAttributeDefinitionDescriptor fetchFromApi(String nameOrId, boolean failIfNotFound) { + var request = unirest.get(FoDUrls.ATTRIBUTES); + JsonNode result; + try { + int id = Integer.parseInt(nameOrId); + result = FoDDataHelper.findUnique(request, String.format("id:%d", id)); + } catch (NumberFormatException nfe) { + result = FoDDataHelper.findUnique(request, String.format("name:%s", nameOrId)); + } + if (result == null && failIfNotFound) { + throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); + } + return result == null ? null : JsonHelper.treeToValue(result, FoDAttributeDefinitionDescriptor.class); + } + + private Map buildEffectiveAttributeUpdates(FoDEnums.AttributeTypes attrType, + ArrayList currentAttributes, + Map userSuppliedUpdates, boolean autoReqdAttributes) { + var effective = new LinkedHashMap(); + if (autoReqdAttributes) { + Set covered = new HashSet<>(); + if (currentAttributes != null) { + currentAttributes.stream() + .filter(a -> StringUtils.isNotBlank(a.getValue())) + .map(FoDAttributeValueDescriptor::getName) + .forEach(covered::add); + } + if (userSuppliedUpdates != null) { covered.addAll(userSuppliedUpdates.keySet()); } + getRequiredDefaultValues(attrType).forEach((k, v) -> { if (!covered.contains(k)) effective.put(k, v); }); + } + if (userSuppliedUpdates != null) { effective.putAll(userSuppliedUpdates); } + return effective; + } + + private String getDefaultValue(FoDAttributeDefinitionDescriptor def) { + if (StringUtils.isNotBlank(def.getDefaultValue())) { return def.getDefaultValue(); } + return switch (def.getAttributeDataType()) { + case "Text" -> "autofilled by fcli"; + case "Boolean" -> String.valueOf(false); + case "User", "Picklist" -> def.getPicklistValues() != null && !def.getPicklistValues().isEmpty() + ? def.getPicklistValues().get(0).getName() : null; + default -> null; + }; + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java deleted file mode 100644 index 7b9c3cbba9a..00000000000 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.fod.attribute.helper; - -import java.util.*; - -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.rest.unirest.HttpHeader; -import com.fortify.cli.fod._common.rest.FoDUrls; -import com.fortify.cli.fod._common.rest.helper.FoDDataHelper; -import com.fortify.cli.fod._common.util.FoDEnums; - -import kong.unirest.GetRequest; -import kong.unirest.UnirestInstance; -import lombok.Getter; -import lombok.SneakyThrows; - -public class FoDAttributeHelper { - private static final Logger LOG = LoggerFactory.getLogger(FoDAttributeHelper.class); - @Getter private static ObjectMapper objectMapper = new ObjectMapper(); - - public static final FoDAttributeDescriptor getAttributeDescriptor(UnirestInstance unirestInstance, String attrNameOrId, boolean failIfNotFound) { - GetRequest request = unirestInstance.get(FoDUrls.ATTRIBUTES); - JsonNode result = null; - try { - int attrId = Integer.parseInt(attrNameOrId); - result = FoDDataHelper.findUnique(request, String.format("id:%d", attrId)); - } catch (NumberFormatException nfe) { - result = FoDDataHelper.findUnique(request, String.format("name:%s", attrNameOrId)); - } - if ( failIfNotFound && result==null ) { - throw new FcliSimpleException("No attribute found for name or id: " + attrNameOrId); - } - return result==null ? null : JsonHelper.treeToValue(result, FoDAttributeDescriptor.class); - } - - @SneakyThrows - public static final Map getRequiredAttributesDefaultValues(UnirestInstance unirestInstance, - FoDEnums.AttributeTypes attrType) { - Map reqAttrs = new HashMap<>(); - GetRequest request = unirestInstance.get(FoDUrls.ATTRIBUTES) - .queryString("filters", "isRequired:true"); - JsonNode items = request.asObject(ObjectNode.class).getBody().get("items"); - List lookupList = objectMapper.readValue(objectMapper.writeValueAsString(items), - new TypeReference>() { - }); - Iterator lookupIterator = lookupList.iterator(); - while (lookupIterator.hasNext()) { - FoDAttributeDescriptor currentLookup = lookupIterator.next(); - // currentLookup.getAttributeTypeId() == 1 if "Application", 4 if "Release" - filter above does not support querying on this yet! - if (currentLookup.getIsRequired() && (attrType.getValue() == 0 || currentLookup.getAttributeTypeId() == attrType.getValue())) { - var defaultValue = getDefaultValue(currentLookup); - if (defaultValue != null) { - reqAttrs.put(currentLookup.getName(), defaultValue); - } - } - } - return reqAttrs; - } - - public static JsonNode mergeAttributesNode(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, - ArrayList current, - Map updates) { - ArrayNode attrArray = objectMapper.createArrayNode(); - if (updates == null || updates.isEmpty()) return attrArray; - - // Map attribute id to value from updates - Map updatesWithId = new HashMap<>(); - for (Map.Entry attr : updates.entrySet()) { - FoDAttributeDescriptor desc = getAttributeDescriptor(unirest, attr.getKey(), true); - updatesWithId.put(desc.getId(), attr.getValue()); - } - - // Track which ids have been processed - Set processedIds = new HashSet<>(); - - // Add current attributes, updating values if present in updates - if (current != null) { - for (FoDAttributeDescriptor attr : current) { - int id = attr.getId(); - ObjectNode attrObj = objectMapper.createObjectNode(); - attrObj.put("id", id); - attrObj.put("value", updatesWithId.getOrDefault(id, attr.getValue())); - attrArray.add(attrObj); - processedIds.add(id); - } - } - - // Add new attributes from updates not already in current - for (Map.Entry entry : updatesWithId.entrySet()) { - if (!processedIds.contains(entry.getKey())) { - ObjectNode attrObj = objectMapper.createObjectNode(); - attrObj.put("id", entry.getKey()); - attrObj.put("value", entry.getValue()); - attrArray.add(attrObj); - } - } - - return attrArray; - } - - public static JsonNode getAttributesNode(FoDEnums.AttributeTypes attrType, ArrayList attributes) { - ArrayNode attrArray = objectMapper.createArrayNode(); - if (attributes == null || attributes.isEmpty()) return attrArray; - for (FoDAttributeDescriptor attr : attributes) { - ObjectNode attrObj = objectMapper.createObjectNode(); - attrObj.put("id", attr.getId()); - attrObj.put("value", attr.getValue()); - attrArray.add(attrObj); - } - return attrArray; - } - - /** - * For create commands: amends user-provided attribute values with server-side defaults - * for any required attributes not already specified by the user. - * Resolves attribute names to IDs and filters by attrType. - */ - public static JsonNode getAttributesNode(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, - Map attributesMap, boolean autoReqdAttributes) { - var effectiveMap = buildEffectiveAttributeUpdates(unirest, attrType, null, attributesMap, autoReqdAttributes); - ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - for (Map.Entry attr : effectiveMap.entrySet()) { - ObjectNode attrObj = getObjectMapper().createObjectNode(); - FoDAttributeDescriptor attributeDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attr.getKey(), true); - // filter out any attributes that aren't valid for the entity we are working on, e.g. Application or Release - if (attrType.getValue() == 0 || attributeDescriptor.getAttributeTypeId() == attrType.getValue()) { - attrObj.put("id", attributeDescriptor.getId()); - attrObj.put("value", attr.getValue()); - attrArray.add(attrObj); - } else { - LOG.debug("Skipping attribute '"+attributeDescriptor.getName()+"' as it is not a "+attrType.toString()+" attribute"); - } - } - return attrArray; - } - - /** - * For update commands: merges user-supplied attribute values with the entity's existing - * attribute values, then amends with server-side defaults for any required attributes - * not already covered by either the current values or user-supplied updates. - */ - public static JsonNode getAttributesNodeForUpdate(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, - ArrayList currentAttributes, Map userSuppliedUpdates, - boolean autoReqdAttributes) { - var effectiveUpdates = buildEffectiveAttributeUpdates( - unirest, attrType, currentAttributes, userSuppliedUpdates, autoReqdAttributes); - return effectiveUpdates.isEmpty() - ? getAttributesNode(attrType, currentAttributes) - : mergeAttributesNode(unirest, attrType, currentAttributes, effectiveUpdates); - } - - /** - * Computes the effective attribute updates to apply. For required attributes not already - * covered by current entity values or user-supplied updates, server-side defaults are added. - * User-supplied updates always take highest priority. - * Pass {@code currentAttributes=null} for create scenarios. - */ - private static Map buildEffectiveAttributeUpdates(UnirestInstance unirest, - FoDEnums.AttributeTypes attrType, ArrayList currentAttributes, - Map userSuppliedUpdates, boolean autoReqdAttributes) { - var effective = new LinkedHashMap(); - if (autoReqdAttributes) { - Set covered = new HashSet<>(); - if (currentAttributes != null) { - currentAttributes.stream() - .filter(a -> StringUtils.isNotBlank(a.getValue())) - .map(FoDAttributeDescriptor::getName) - .forEach(covered::add); - } - if (userSuppliedUpdates != null) { - covered.addAll(userSuppliedUpdates.keySet()); - } - getRequiredAttributesDefaultValues(unirest, attrType) - .forEach((k, v) -> { if (!covered.contains(k)) effective.put(k, v); }); - } - if (userSuppliedUpdates != null) { - effective.putAll(userSuppliedUpdates); - } - return effective; - } - - public static FoDAttributeDescriptor createAttribute(UnirestInstance unirest, FoDAttributeCreateRequest request) { - var response = unirest.post(FoDUrls.ATTRIBUTES) - // Use headerReplace to replace rather than add the Content-Type header (avoid duplicates with defaults) - .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") - .body(request) - .asObject(JsonNode.class) - .getBody(); - if (response.has("success") && response.get("success").asBoolean()) { - if (!response.has("attributeId")) { - throw new FcliSimpleException("Response missing attributeId: " + response.toString()); - } - var attributeId = response.get("attributeId").asText(); - return getAttributeDescriptor(unirest, attributeId, true); - } else { - throw new FcliSimpleException("Failed to create attribute: " + response.toString()); - } - - } - - public static FoDAttributeDescriptor updateAttribute(UnirestInstance unirest, String attributeId, FoDAttributeUpdateRequest request) { - var response = unirest.put(FoDUrls.ATTRIBUTE) - .routeParam("attributeId", attributeId) - .body(request) - .asObject(JsonNode.class) - .getBody(); - if (response.has("success") && response.get("success").asBoolean()) { - return getAttributeDescriptor(unirest, attributeId, true); - } else { - throw new FcliSimpleException("Failed to update attribute: " + response.toString()); - } - - } - - private static String getDefaultValue(FoDAttributeDescriptor attribute) { - if (StringUtils.isNotBlank(attribute.getDefaultValue())) { - return attribute.getDefaultValue(); - } - return switch (attribute.getAttributeDataType()) { - case "Text" -> "autofilled by fcli"; - case "Boolean" -> String.valueOf(false); - case "User", "Picklist" -> attribute.getPicklistValues() != null && !attribute.getPicklistValues().isEmpty() - ? attribute.getPicklistValues().get(0).getName() : null; - default -> null; - }; - } -} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java new file mode 100644 index 00000000000..21c2c8c5d29 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.attribute.helper; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.json.JsonNodeHolder; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Represents an attribute value attached to a concrete FoD entity (application, release, + * microservice, scan, issue). Contains only the id, name, and current value — as returned + * by entity endpoints. For the full attribute schema see {@link FoDAttributeDefinitionDescriptor}. + */ +@Data @EqualsAndHashCode(callSuper = true) +@Reflectable @NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class FoDAttributeValueDescriptor extends JsonNodeHolder { + private Integer id; + private String name; + private String value; +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java index 55075a320a6..b635561c23a 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java @@ -19,7 +19,6 @@ import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.mcp.MCPInclude; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -31,6 +30,7 @@ import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; import com.fortify.cli.fod.issue.helper.FoDBulkIssueUpdateRequest; import com.fortify.cli.fod.issue.helper.FoDBulkIssueUpdateResponse; +import com.fortify.cli.fod.issue.helper.FoDIssueAttributeHelper; import com.fortify.cli.fod.issue.helper.FoDIssueHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; @@ -54,7 +54,6 @@ public class FoDIssueUpdateCommand extends AbstractFoDJsonNodeOutputCommand impl @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; @Mixin private FoDAttributeUpdateOptions.OptionalAttrOption issueAttrsUpdate; - private final ObjectMapper objectMapper = new ObjectMapper(); @Option(names = {"--user"}, required = true) protected String user; @@ -72,6 +71,7 @@ public class FoDIssueUpdateCommand extends AbstractFoDJsonNodeOutputCommand impl @Override public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); + var issueAttrHelper = new FoDIssueAttributeHelper(unirest); // If vulnIds are provided, filter them against the release vulnerabilities using a helper. int issueUpdateCount = 0; @@ -90,10 +90,10 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } Map attributeUpdates = issueAttrsUpdate.getAttributes(); - JsonNode jsonAttrs = FoDIssueHelper.buildIssueAttributesNode(unirest, attributeUpdates); + JsonNode jsonAttrs = issueAttrHelper.buildAttributesNode(attributeUpdates); // Validate auditor and developer status values against attribute picklists - ResolvedStatuses resolvedStatuses = resolveStatuses(unirest); + ResolvedStatuses resolvedStatuses = resolveStatuses(issueAttrHelper); FoDBulkIssueUpdateRequest issueUpdateRequest = buildIssueUpdateRequest(unirest, resolvedStatuses.developerStatusValue(), resolvedStatuses.auditorStatusValue(), jsonAttrs); FoDBulkIssueUpdateResponse resp = performUpdate(unirest, releaseDescriptor.getReleaseId(), issueUpdateRequest, totalCount, skippedCount, issueUpdateCount); @@ -106,16 +106,16 @@ public JsonNode getJsonNode(UnirestInstance unirest) { private record ResolvedStatuses(String developerStatusValue, String auditorStatusValue) {} - private ResolvedStatuses resolveStatuses(UnirestInstance unirest) { + private ResolvedStatuses resolveStatuses(FoDIssueAttributeHelper issueAttrHelper) { String auditorStatusValue = null; if ( auditorStatus != null && !auditorStatus.isBlank() ) { - auditorStatusValue = FoDIssueHelper.resolveStatusValue(unirest, auditorStatus, new String[]{ + auditorStatusValue = issueAttrHelper.resolveStatusValue(auditorStatus, new String[]{ "Auditor Status (Non suppressed)", "Auditor Status (Suppressed)" }, "auditor-status", AuditorStatusType.values()); } String developerStatusValue = null; if ( developerStatus != null && !developerStatus.isBlank() ) { - developerStatusValue = FoDIssueHelper.resolveStatusValue(unirest, developerStatus, new String[]{ + developerStatusValue = issueAttrHelper.resolveStatusValue(developerStatus, new String[]{ "Developer Status (Open)", "Developer Status (Closed)" }, "developer-status", DeveloperStatusType.values()); } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java new file mode 100644 index 00000000000..af894a08d36 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java @@ -0,0 +1,144 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.issue.helper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.fod._common.util.FoDEnums; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; + +import kong.unirest.UnirestInstance; + +/** + * Instance-based helper for FoD issue attribute operations. Delegates definition lookups to + * {@link FoDAttributeDefinitionHelper} and provides issue-specific attribute node building and + * status value resolution. Accepts a caller-supplied definition helper to avoid redundant API + * calls when the caller already holds one, or creates its own from a {@link UnirestInstance}. + * Intended to be instantiated once per command execution; never stored statically. + */ +public class FoDIssueAttributeHelper { + private static final Logger LOG = LoggerFactory.getLogger(FoDIssueAttributeHelper.class); + private final FoDAttributeDefinitionHelper definitionHelper; + + public FoDIssueAttributeHelper(UnirestInstance unirest) { + this(new FoDAttributeDefinitionHelper(unirest)); + } + + public FoDIssueAttributeHelper(FoDAttributeDefinitionHelper definitionHelper) { + this.definitionHelper = definitionHelper; + } + + /** + * Builds an ArrayNode of {id, value} objects for issue attribute updates, filtering to + * Issue-scoped attributes only. + */ + public ArrayNode buildAttributesNode(Map attributeUpdates) { + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + if (attributeUpdates == null || attributeUpdates.isEmpty()) { return attrArray; } + for (var entry : attributeUpdates.entrySet()) { + var def = definitionHelper.getDefinition(entry.getKey(), false); + if (def == null) { + LOG.warn("Attribute '{}' not found, skipping", entry.getKey()); + continue; + } + if (Objects.equals(def.getAttributeTypeId(), FoDEnums.AttributeTypes.Issue.getValue())) { + var obj = JsonHelper.getObjectMapper().createObjectNode(); + obj.put("id", def.getId()); + obj.put("value", entry.getValue()); + attrArray.add(obj); + } else { + LOG.debug("Skipping attribute '{}' as it is not an Issue attribute", def.getName()); + } + } + return attrArray; + } + + /** + * Resolves a developer/auditor status value against attribute picklists, inferring the + * relevant enum type from the option name. + */ + public String resolveStatusValue(String providedValue, String[] attributeNames, String optionName) { + if (optionName != null && optionName.toLowerCase().contains("developer")) { + return resolveStatusValue(providedValue, attributeNames, optionName, FoDEnums.DeveloperStatusType.values()); + } else if (optionName != null && optionName.toLowerCase().contains("auditor")) { + return resolveStatusValue(providedValue, attributeNames, optionName, FoDEnums.AuditorStatusType.values()); + } + return resolveStatusValue(providedValue, attributeNames, optionName, (FoDEnums.DeveloperStatusType[]) null); + } + + /** + * Resolves a status value against the given enum values first, then against attribute picklists. + * Throws a {@link FcliSimpleException} listing allowed values if resolution fails. + */ + public & FoDEnums.IFoDEnumValueSupplier> String resolveStatusValue( + String providedValue, String[] attributeNames, String optionName, T[] enumValues) { + if (providedValue == null || providedValue.isBlank()) { return null; } + String originalProvided = providedValue; + String candidate = providedValue.trim(); + try { + if (enumValues != null) { + var resolved = FoDEnums.IFoDEnumValueSupplier.resolveEnumValue(candidate, enumValues); + if (resolved.isPresent()) { candidate = resolved.get(); } + } + } catch (Exception e) { + LOG.debug("Error resolving enum-style status value for {}: {}", optionName, e.getMessage()); + } + + String attrResolved = tryResolveAgainstAttributes(attributeNames, candidate); + if (attrResolved != null) { return attrResolved; } + + var allowed = collectAllowedAttributeValues(attributeNames); + throw new FcliSimpleException(String.format("Invalid %s '%s'. Allowed values: %s", + optionName, originalProvided, String.join(", ", allowed))); + } + + private String tryResolveAgainstAttributes(String[] attributeNames, String candidate) { + for (String attrName : attributeNames) { + var def = definitionHelper.getDefinition(attrName, false); + if (def == null) { continue; } + var picklist = def.getPicklistValues(); + if (picklist == null || picklist.isEmpty()) { continue; } + for (var pv : picklist) { + if (pv.getName() != null && pv.getName().equalsIgnoreCase(candidate)) { return pv.getName(); } + } + try { + int providedId = Integer.parseInt(candidate); + for (var pv : picklist) { + if (Objects.equals(pv.getId(), providedId)) { return pv.getName(); } + } + } catch (NumberFormatException ignored) {} + } + return null; + } + + private List collectAllowedAttributeValues(String[] attributeNames) { + var allowed = new ArrayList(); + for (String attrName : attributeNames) { + var def = definitionHelper.getDefinition(attrName, false); + if (def == null) { continue; } + var picklist = def.getPicklistValues(); + if (picklist == null) { continue; } + for (var pv : picklist) { allowed.add(pv.getName()); } + } + return allowed; + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java index e6e4a906f8b..5a355d9534f 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java @@ -19,18 +19,11 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.json.transform.fields.RenameFieldsTransformer; @@ -38,97 +31,13 @@ import com.fortify.cli.fod._common.rest.helper.FoDInputTransformer; import com.fortify.cli.fod._common.rest.helper.FoDPagingHelper; import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; -import com.fortify.cli.fod._common.util.FoDEnums; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import kong.unirest.HttpResponse; import kong.unirest.UnirestInstance; import lombok.Builder; import lombok.Data; -import lombok.Getter; public class FoDIssueHelper { - private static final Logger LOG = LoggerFactory.getLogger(FoDIssueHelper.class); - @Getter private static ObjectMapper objectMapper = new ObjectMapper(); - - // Local cache for attribute descriptors used during bulk issue updates. Populated by loadAllAttributes(). - private static final ConcurrentHashMap ATTR_CACHE_BY_NAME = new ConcurrentHashMap<>(); - private static final ConcurrentHashMap ATTR_CACHE_BY_ID = new ConcurrentHashMap<>(); - private static volatile boolean attributesPrefetched = false; - - /** - * Prefetch all attributes from FoD and populate the local cache. Safe to call multiple times; will perform - * the fetch only once per JVM unless clearAttributesCache() is called. - */ - public static synchronized void loadAllAttributes(UnirestInstance unirest) { - if ( attributesPrefetched ) return; - // Use local temporary maps to build the cache so a failed fetch/parse doesn't leave partial data - var tmpById = new HashMap(); - var tmpByName = new HashMap(); - try { - var request = unirest.get(FoDUrls.ATTRIBUTES); - var body = request.asObject(ObjectNode.class).getBody(); - var items = body.get("items"); - if ( items!=null && items.isArray() ) { - for (var item : items) { - FoDAttributeDescriptor desc = JsonHelper.treeToValue(item, FoDAttributeDescriptor.class); - if ( desc!=null ) { - tmpById.putIfAbsent(desc.getId(), desc); - if ( desc.getName()!=null ) { - tmpByName.putIfAbsent(desc.getName(), desc); - tmpByName.putIfAbsent(desc.getName().trim(), desc); - } - } - } - } - // Merge into the concurrent caches; preserve any existing descriptors using putIfAbsent - for ( var e : tmpById.entrySet() ) { - ATTR_CACHE_BY_ID.putIfAbsent(e.getKey(), e.getValue()); - } - for ( var e : tmpByName.entrySet() ) { - ATTR_CACHE_BY_NAME.putIfAbsent(e.getKey(), e.getValue()); - } - attributesPrefetched = true; // set only after successful population - } catch (kong.unirest.UnirestException e) { - throw new FcliTechnicalException("Error loading attribute descriptors", e); - } catch (Exception e) { - throw new FcliTechnicalException("Error processing attribute descriptors", e); - } - } - - public static void clearAttributesCache() { - ATTR_CACHE_BY_ID.clear(); - ATTR_CACHE_BY_NAME.clear(); - attributesPrefetched = false; - } - - /** - * Resolve an attribute descriptor from the local cache. If not prefetched yet, will call loadAllAttributes. - */ - public static FoDAttributeDescriptor getAttributeDescriptorFromCache(UnirestInstance unirest, String nameOrId, boolean failIfNotFound) { - if ( !attributesPrefetched ) { - loadAllAttributes(unirest); - } - if ( nameOrId==null ) { - if ( failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: null"); - return null; - } - try { - int id = Integer.parseInt(nameOrId); - var desc = ATTR_CACHE_BY_ID.get(id); - if ( desc==null && failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); - return desc; - } catch (NumberFormatException nfe) { - var desc = ATTR_CACHE_BY_NAME.get(nameOrId); - if ( desc==null ) { - // try trimmed - desc = ATTR_CACHE_BY_NAME.get(nameOrId.trim()); - } - if ( desc==null && failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); - return desc; - } - } public static final JsonNode transformRecord(JsonNode record) { return new RenameFieldsTransformer(new String[]{}).transform(record); @@ -212,7 +121,7 @@ public static final ObjectNode transformRecord(ObjectNode record, IssueAggregati } public static final FoDBulkIssueUpdateResponse updateIssues(UnirestInstance unirest, String releaseId, FoDBulkIssueUpdateRequest issueUpdateRequest) { - ObjectNode body = objectMapper.valueToTree(issueUpdateRequest); + ObjectNode body = JsonHelper.getObjectMapper().valueToTree(issueUpdateRequest); var result = unirest.post(FoDUrls.VULNERABILITIES + "/bulk-edit") .routeParam("relId", releaseId) .body(body).asObject(JsonNode.class).getBody(); @@ -365,80 +274,6 @@ public static java.util.Set getVulnIdsForRelease(UnirestInstance unirest return result; } - /** - * Resolve a status value (developer/auditor) against one or more FoD attribute picklists. - * Returns the canonical picklist name when found, or throws a FcliSimpleException listing allowed values. - */ - public static String resolveStatusValue(UnirestInstance unirest, String providedValue, String[] attributeNames, String optionName) { - // Maintain compatibility by delegating to the generic overload; try to infer enum when optionName indicates developer/auditor - FoDEnums.DeveloperStatusType[] devEnum = FoDEnums.DeveloperStatusType.values(); - FoDEnums.AuditorStatusType[] audEnum = FoDEnums.AuditorStatusType.values(); - if ( optionName!=null && optionName.toLowerCase().contains("developer") ) { - return resolveStatusValue(unirest, providedValue, attributeNames, optionName, devEnum); - } else if ( optionName!=null && optionName.toLowerCase().contains("auditor") ) { - return resolveStatusValue(unirest, providedValue, attributeNames, optionName, audEnum); - } - return resolveStatusValue(unirest, providedValue, attributeNames, optionName, (FoDEnums.DeveloperStatusType[])null); - } - - public static & FoDEnums.IFoDEnumValueSupplier> String resolveStatusValue(UnirestInstance unirest, String providedValue, String[] attributeNames, String optionName, T[] enumValues) { - if ( providedValue==null || providedValue.isBlank() ) { return null; } - String originalProvided = providedValue; - String candidate = providedValue.trim(); - try { - if ( enumValues!=null ) { - var resolved = FoDEnums.IFoDEnumValueSupplier.resolveEnumValue(candidate, enumValues); - if ( resolved.isPresent() ) { candidate = resolved.get(); } - } - } catch (Exception e) { - LOG.debug("Error resolving enum-style status value for {}: {}", optionName, e.getMessage()); - } - - String attrResolved = tryResolveAgainstAttributes(unirest, attributeNames, candidate); - if ( attrResolved!=null ) return attrResolved; - - var allowed = collectAllowedAttributeValues(unirest, attributeNames); - throw new FcliSimpleException(String.format("Invalid %s '%s'. Allowed values: %s", optionName, originalProvided, String.join(", ", allowed))); - } - - private static String tryResolveAgainstAttributes(UnirestInstance unirest, String[] attributeNames, String candidate) { - for (String attrName: attributeNames) { - var desc = FoDAttributeHelper.getAttributeDescriptor(unirest, attrName, false); - if ( desc==null ) continue; - var picklist = desc.getPicklistValues(); - if ( picklist==null || picklist.isEmpty() ) continue; - for (var pv : picklist) { - if ( pv.getName()!=null && pv.getName().equalsIgnoreCase(candidate) ) { - return pv.getName(); - } - } - // if provided value looks like an id, try matching by id - try { - int providedId = Integer.parseInt(candidate); - for (var pv : picklist) { - if ( Objects.equals(pv.getId(), providedId) ) { - return pv.getName(); - } - } - } catch (NumberFormatException ignored) {} - } - return null; - } - - private static List collectAllowedAttributeValues(UnirestInstance unirest, String[] attributeNames) { - var allowed = new ArrayList(); - for (String attrName: attributeNames) { - var desc = FoDAttributeHelper.getAttributeDescriptor(unirest, attrName, false); - if ( desc==null ) continue; - var picklist = desc.getPicklistValues(); - if ( picklist==null ) continue; - for (var pv: picklist) { - allowed.add(pv.getName()); - } - } - return allowed; - } - /** * Result carrier for vuln filtering: kept (normalized ids to update), skipped (original values skipped), totalCount */ @@ -490,30 +325,4 @@ private static String normalizeVulnId(String id) { } return v.isEmpty() ? null : v; } - - /** - * Build an ArrayNode of attribute objects (id/value) for Issue attribute updates using the localized - * attribute cache. Will prefetch attributes if necessary. - */ - public static ArrayNode buildIssueAttributesNode(UnirestInstance unirest, Map attributeUpdates) { - ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - if ( attributeUpdates==null || attributeUpdates.isEmpty() ) return attrArray; - // Ensure local cache populated - loadAllAttributes(unirest); - for ( Map.Entry e : attributeUpdates.entrySet() ) { - String attrName = e.getKey(); - String value = e.getValue(); - FoDAttributeDescriptor attributeDescriptor = getAttributeDescriptorFromCache(unirest, attrName, true); - // Only include attributes that are Issue-scoped (AttributeTypes.Issue) - if ( attributeDescriptor!=null && attributeDescriptor.getAttributeTypeId() == FoDEnums.AttributeTypes.Issue.getValue() ) { - var obj = JsonHelper.getObjectMapper().createObjectNode(); - obj.put("id", attributeDescriptor.getId()); - obj.put("value", value); - attrArray.add(obj); - } else { - LOG.debug("Skipping attribute '{}' as it is not an Issue attribute", attributeDescriptor.getName()); - } - } - return attrArray; - } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java index 6ce2013820e..1a6f78c5023 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java @@ -21,7 +21,7 @@ import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.microservice.cli.mixin.FoDMicroserviceByQualifiedNameResolverMixin; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; @@ -54,7 +54,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { FoDQualifiedMicroserviceNameDescriptor qualifiedMicroserviceNameDescriptor = qualifiedMicroserviceNameResolver.getQualifiedMicroserviceNameDescriptor(); FoDMicroserviceUpdateRequest msCreateRequest = FoDMicroserviceUpdateRequest.builder() .microserviceName(qualifiedMicroserviceNameDescriptor.getMicroserviceName()) - .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, + .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Microservice, msAttrs.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs())) .build(); return FoDMicroserviceHelper.createMicroservice(unirest, appDescriptor, msCreateRequest).asJsonNode(); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java index a37ea2d6edc..d60884ffd64 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java @@ -16,15 +16,13 @@ import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; -import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import com.fortify.cli.fod.microservice.cli.mixin.FoDMicroserviceByQualifiedNameResolverMixin; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; @@ -39,7 +37,6 @@ @Command(name = OutputHelperMixins.Update.CMD_NAME) public class FoDMicroserviceUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.Update outputHelper; - private final ObjectMapper objectMapper = new ObjectMapper(); @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDMicroserviceByQualifiedNameResolverMixin.PositionalParameter microserviceResolver; @@ -52,20 +49,18 @@ public class FoDMicroserviceUpdateCommand extends AbstractFoDJsonNodeOutputComma @Override public JsonNode getJsonNode(UnirestInstance unirest) { FoDMicroserviceDescriptor msDescriptor = microserviceResolver.getMicroserviceDescriptor(unirest, true); - ArrayList msAttrsCurrent = msDescriptor.getAttributes(); + ArrayList msAttrsCurrent = msDescriptor.getAttributes(); Map attributeUpdates = msAttrsUpdate.getAttributes(); - JsonNode jsonAttrs = objectMapper.createArrayNode(); + JsonNode jsonAttrs; if (attributeUpdates != null && !attributeUpdates.isEmpty()) { - jsonAttrs = FoDAttributeHelper.mergeAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, - msAttrsCurrent, attributeUpdates); + jsonAttrs = new FoDAttributeDefinitionHelper(unirest).mergeAttributesNode(msAttrsCurrent, attributeUpdates); } else { - jsonAttrs = FoDAttributeHelper.getAttributesNode(FoDEnums.AttributeTypes.Microservice, msAttrsCurrent); + jsonAttrs = FoDAttributeDefinitionHelper.attributeValuesToNode(msAttrsCurrent); } - FoDMicroserviceDescriptor appMicroserviceDescriptor = microserviceResolver.getMicroserviceDescriptor(unirest, true); FoDMicroserviceUpdateRequest msUpdateRequest = FoDMicroserviceUpdateRequest.builder() .microserviceName(microserviceName) .attributes(jsonAttrs).build(); - return FoDMicroserviceHelper.updateMicroservice(unirest, appMicroserviceDescriptor, msUpdateRequest).asJsonNode(); + return FoDMicroserviceHelper.updateMicroservice(unirest, msDescriptor, msUpdateRequest).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java index d1b2d7c6933..8e4c3cc1487 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java @@ -18,7 +18,7 @@ import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -31,11 +31,11 @@ public class FoDMicroserviceDescriptor extends JsonNodeHolder { private String applicationName; private String microserviceId; private String microserviceName; - private ArrayList attributes; + private ArrayList attributes; public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeDescriptor attr : attributes) { + for (FoDAttributeValueDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java index 23d543530fc..94962745dc3 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java @@ -26,7 +26,7 @@ import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import kong.unirest.UnirestInstance; @@ -99,7 +99,7 @@ public static final FoDMicroserviceDescriptor createMicroservice(UnirestInstance boolean autoRequiredAttrs) { var request = FoDMicroserviceUpdateRequest.builder() .microserviceName(microserviceName) - .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, + .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Microservice, msAttrs.getAttributes(), autoRequiredAttrs)) .build(); return createMicroservice(unirest, appDescriptor, request); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java index 50080122c7b..1ddcba8553d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java @@ -40,7 +40,7 @@ import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; @@ -150,7 +150,7 @@ private final ObjectNode createRelease(UnirestInstance unirest, FoDAppDescriptor .releaseName(simpleReleaseName) .releaseDescription(description) .sdlcStatusType(sdlcStatus.getSdlcStatusType().name()) - .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Release, + .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Release, relAttrs.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs())); requestBuilder = addMicroservice(microserviceDescriptor, requestBuilder); requestBuilder = addCopyFrom(unirest, appDescriptor, requestBuilder); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java index 8649b783452..41d0c73f61f 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java @@ -25,7 +25,7 @@ import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.app.cli.mixin.FoDSdlcStatusTypeOptions; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; import com.fortify.cli.fod.release.helper.FoDReleaseHelper; @@ -63,7 +63,7 @@ public class FoDReleaseUpdateCommand extends AbstractFoDJsonNodeOutputCommand im public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); FoDSdlcStatusTypeOptions.FoDSdlcStatusType sdlcStatusTypeNew = sdlcStatus.getSdlcStatusType(); - JsonNode jsonAttrs = FoDAttributeHelper.getAttributesNodeForUpdate(unirest, FoDEnums.AttributeTypes.Release, + JsonNode jsonAttrs = new FoDAttributeDefinitionHelper(unirest).buildAttributesNodeForUpdate(FoDEnums.AttributeTypes.Release, releaseDescriptor.getAttributes(), appAttrsUpdate.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs()); FoDReleaseUpdateRequest appRelUpdateRequest = FoDReleaseUpdateRequest.builder() diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java index fdea71e369d..cdb54a3e9d2 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java @@ -22,7 +22,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -58,7 +58,7 @@ public class FoDReleaseDescriptor extends JsonNodeHolder { private LocalDateTime staticScanDate; private LocalDateTime dynamicScanDate; private LocalDateTime mobileScanDate; - private ArrayList attributes; + private ArrayList attributes; @JsonIgnore public String getQualifiedName() { return StringUtils.isBlank(microserviceName) @@ -74,7 +74,7 @@ public String getQualifierPrefix(String delimiter) { public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeDescriptor attr : attributes) { + for (FoDAttributeValueDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; From 48bbf35c711405d5ffbcaaaf7dc1567408241b69 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 13 May 2026 14:16:59 +0200 Subject: [PATCH 18/55] chore: Minor code improvements --- .../main/java/com/fortify/cli/app/FortifyCLI.java | 5 ----- .../cli/app/runner/DefaultFortifyCLIRunner.java | 4 +--- .../fortify/cli/common/cli/util/StdioHelper.java | 13 +++++++++++++ .../access_control/helper/SSCTokenConverter.java | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/FortifyCLI.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/FortifyCLI.java index 75497030b09..f2ab435cb79 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/FortifyCLI.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/FortifyCLI.java @@ -15,8 +15,6 @@ import com.fortify.cli.app.runner.DefaultFortifyCLIRunner; import com.fortify.cli.common.util.ConsoleHelper; -import picocli.CommandLine.Help.Ansi; - /** *

This class provides the {@link #main(String[])} entrypoint into the application, * and also registers some GraalVM features, allowing the application to run properly @@ -36,9 +34,6 @@ public static final void main(String[] args) { private static final int execute(String[] args) { try { ConsoleHelper.installJAnsiConsole(); - // ANSI detection must happen before StdioHelper.install(), as masking - // may interfere with ANSI capability detection - DefaultFortifyCLIRunner.ANSI = Ansi.AUTO.enabled() ? Ansi.ON : Ansi.AUTO; return DefaultFortifyCLIRunner.run(args); } finally { ConsoleHelper.uninstallJAnsiConsole(); diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/DefaultFortifyCLIRunner.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/DefaultFortifyCLIRunner.java index 3a1bce0bb12..fd76b41aafc 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/DefaultFortifyCLIRunner.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/DefaultFortifyCLIRunner.java @@ -34,14 +34,12 @@ import picocli.CommandLine; import picocli.CommandLine.Help; -import picocli.CommandLine.Help.Ansi; import picocli.CommandLine.Help.Ansi.Text; import picocli.CommandLine.Model.ArgGroupSpec; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Model.OptionSpec; public final class DefaultFortifyCLIRunner { - public static Ansi ANSI = Help.Ansi.AUTO; // TODO See https://github.com/remkop/picocli/issues/2066 //@Getter(value = AccessLevel.PRIVATE, lazy = true) //private final CommandLine commandLine = createCommandLine(); @@ -78,7 +76,7 @@ private static Supplier prepareExecution(NormalizedArgs normalizedArgs) private static final CommandLine createCommandLine(boolean useWrapperHelp) { FortifyCLIStaticInitializer.getInstance().initialize(); CommandLine cl = new CommandLine(FCLIRootCommands.class); - cl.setColorScheme(Help.defaultColorScheme(ANSI)); + cl.setColorScheme(Help.defaultColorScheme(StdioHelper.getAnsi())); FcliCommandSpecHelper.setRootCommandLine(cl); // Custom parameter exception handler is disabled for now as it causes https://github.com/fortify/fcli/issues/434. // See comments in I18nParameterExceptionHandler for more detail. diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java index 07f9fade774..890d441a72d 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java @@ -23,6 +23,8 @@ import com.fortify.cli.common.log.LogMaskHelper; import com.fortify.cli.common.output.transform.mask.MaskingPrintStream; +import picocli.CommandLine.Help.Ansi; + /** * Central manager for fcli stdio delegation, masking, and progress streams. * @@ -58,6 +60,7 @@ private StdioHelper() {} private static final ThreadLocal> progressCallback = new ThreadLocal<>(); private static volatile boolean installed = false; + private static volatile Ansi ansi = Ansi.AUTO; private static PrintStream rawOut = System.out; private static PrintStream rawErr = System.err; private static PrintStream maskedOut = System.out; @@ -71,6 +74,9 @@ private StdioHelper() {} */ public static synchronized void install() { if ( installed ) return; + // Detect ANSI capability before replacing streams: the delegating/masking + // wrappers installed below can interfere with terminal-based ANSI probing. + ansi = Ansi.AUTO.enabled() ? Ansi.ON : Ansi.AUTO; rawOut = System.out; rawErr = System.err; LOG.trace("Installing delegating streams; rawOut={}, rawErr={}", @@ -105,6 +111,13 @@ public static synchronized void uninstall() { installed = false; } + /** + * Return the resolved ANSI mode, detected before streams were replaced. + * Always returns {@link Ansi#ON} or {@link Ansi#AUTO} (never {@link Ansi#OFF} + * unless the terminal reported no ANSI support before installation). + */ + public static Ansi getAnsi() { return ansi; } + /** * Return the raw, unmasked {@code System.out} captured before installation. *

Only for protocol I/O (JSON-RPC in RPC/MCP servers). diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/access_control/helper/SSCTokenConverter.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/access_control/helper/SSCTokenConverter.java index 71c8fe7c4e8..b51c34c78b8 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/access_control/helper/SSCTokenConverter.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/access_control/helper/SSCTokenConverter.java @@ -21,7 +21,7 @@ public final class SSCTokenConverter { - private static Pattern applicationTokenPattern = Pattern.compile("^[\\da-f]{8}(?:-[\\da-f]{4}){3}-[\\da-f]{12}$"); + private static final Pattern applicationTokenPattern = Pattern.compile("^[\\da-f]{8}(?:-[\\da-f]{4}){3}-[\\da-f]{12}$"); private SSCTokenConverter() {} public static final String toApplicationToken(String token) { From 797bcd5d6b125bd0c06f004d2d2e938127c4917c Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 13 May 2026 14:48:00 +0200 Subject: [PATCH 19/55] chore: Progress writer stream improvements --- .../mcp/cli/cmd/AgentMCPStartHttpCommand.java | 6 +++++ .../cli/common/cli/util/StdioHelper.java | 26 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index 41ba6330dd1..0d22fb3bad8 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -30,6 +30,7 @@ import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.cli.util.IFcliExecutionContextManager; +import com.fortify.cli.common.cli.util.StdioHelper; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.mcp.MCPExclude; @@ -57,6 +58,11 @@ public class AgentMCPStartHttpCommand extends AbstractRunnableCommand implements @Override public Integer call() throws Exception { + // Suppress progress output — HTTP server has no stdio protocol channel to protect, + // so progress messages on stdout/stderr are unwanted console noise + StdioHelper.setProgressOut(null); + StdioHelper.setProgressErr(null); + var config = MCPServerHttpConfigLoader.load(configPath); var safeReturnMillis = PERIOD_HELPER.parsePeriodToMillis(config.getJobSafeReturn()); diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java index 890d441a72d..d808cc9ea11 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.common.cli.util; +import java.io.OutputStream; import java.io.PrintStream; import java.util.ArrayDeque; import java.util.Deque; @@ -20,6 +21,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fortify.cli.common.exception.FcliBugException; import com.fortify.cli.common.log.LogMaskHelper; import com.fortify.cli.common.output.transform.mask.MaskingPrintStream; @@ -45,8 +47,9 @@ *

{@link #getProgressOut()} / {@link #getProgressErr()} return masked streams * for progress/status output. They default to the masked originals, but can be * overridden via {@link #setProgressOut} / {@link #setProgressErr} (the provided - * stream is auto-wrapped with masking). RPC/MCP servers use these to redirect - * progress away from the JSON-RPC response channel.

+ * stream is auto-wrapped with masking; pass {@code null} to suppress output entirely). + * RPC/MCP servers use these to redirect progress away from the JSON-RPC response channel. + * Both setters must only be called from the install thread (before worker threads start).

* * @author Ruud Senden */ @@ -59,6 +62,7 @@ private StdioHelper() {} private static final ThreadLocal> errStack = ThreadLocal.withInitial(ArrayDeque::new); private static final ThreadLocal> progressCallback = new ThreadLocal<>(); + private static volatile Thread installThread; private static volatile boolean installed = false; private static volatile Ansi ansi = Ansi.AUTO; private static PrintStream rawOut = System.out; @@ -79,6 +83,7 @@ public static synchronized void install() { ansi = Ansi.AUTO.enabled() ? Ansi.ON : Ansi.AUTO; rawOut = System.out; rawErr = System.err; + installThread = Thread.currentThread(); LOG.trace("Installing delegating streams; rawOut={}, rawErr={}", System.identityHashCode(rawOut), System.identityHashCode(rawErr)); System.setOut(new DelegatingPrintStream(() -> { @@ -147,8 +152,13 @@ public static synchronized void uninstall() { /** * Override the progress output stream (e.g. to redirect progress away from * the RPC channel). The provided stream is auto-wrapped with masking. + * Pass {@code null} to suppress all progress output (e.g. for HTTP MCP servers). + *

Must only be called from the install thread (i.e. at startup, + * before worker threads are spawned). Calling from any other thread throws + * {@link FcliBugException}.

*/ public static void setProgressOut(PrintStream ps) { + checkInstallThread("setProgressOut"); LOG.trace("setProgressOut: {}", System.identityHashCode(ps)); progressOut = wrapWithMasking(ps); } @@ -156,8 +166,13 @@ public static void setProgressOut(PrintStream ps) { /** * Override the progress error stream (e.g. to redirect progress away from * the RPC channel). The provided stream is auto-wrapped with masking. + * Pass {@code null} to suppress all progress error output (e.g. for HTTP MCP servers). + *

Must only be called from the install thread (i.e. at startup, + * before worker threads are spawned). Calling from any other thread throws + * {@link FcliBugException}.

*/ public static void setProgressErr(PrintStream ps) { + checkInstallThread("setProgressErr"); LOG.trace("setProgressErr: {}", System.identityHashCode(ps)); progressErr = wrapWithMasking(ps); } @@ -224,7 +239,14 @@ public static PrintStream popErr() { return ps; } + private static void checkInstallThread(String method) { + if (installThread != null && Thread.currentThread() != installThread) { + throw new FcliBugException(method + " must only be called from the install thread"); + } + } + private static PrintStream wrapWithMasking(PrintStream ps) { + if (ps == null) return new PrintStream(OutputStream.nullOutputStream()); return ps instanceof MaskingPrintStream ? ps : new MaskingPrintStream(ps, StdioHelper::mask); } From 6d9735452d88dd9c5608457565946e6885176520 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 13 May 2026 15:32:17 +0200 Subject: [PATCH 20/55] Revert "chore: Refactor FoD attribute handling, remove static caches" This reverts commit 88111e97251d808dd933ae7672512d715fbda3eb. --- .../scan/helper/FoDScanDescriptor.java | 6 +- .../fod/app/cli/cmd/FoDAppUpdateCommand.java | 4 +- .../fod/app/helper/FoDAppCreateRequest.java | 4 +- .../cli/fod/app/helper/FoDAppDescriptor.java | 6 +- .../cli/cmd/FoDAttributeCreateCommand.java | 4 +- .../cli/cmd/FoDAttributeDeleteCommand.java | 6 +- .../cli/cmd/FoDAttributeUpdateCommand.java | 10 +- .../cli/mixin/FoDAttributeResolverMixin.java | 19 +- .../helper/FoDAttributeDefinitionHelper.java | 276 ------------------ ...iptor.java => FoDAttributeDescriptor.java} | 12 +- .../attribute/helper/FoDAttributeHelper.java | 248 ++++++++++++++++ .../helper/FoDAttributeValueDescriptor.java | 35 --- .../issue/cli/cmd/FoDIssueUpdateCommand.java | 14 +- .../issue/helper/FoDIssueAttributeHelper.java | 144 --------- .../cli/fod/issue/helper/FoDIssueHelper.java | 193 +++++++++++- .../cli/cmd/FoDMicroserviceCreateCommand.java | 4 +- .../cli/cmd/FoDMicroserviceUpdateCommand.java | 19 +- .../helper/FoDMicroserviceDescriptor.java | 6 +- .../helper/FoDMicroserviceHelper.java | 4 +- .../cli/cmd/FoDReleaseCreateCommand.java | 4 +- .../cli/cmd/FoDReleaseUpdateCommand.java | 4 +- .../release/helper/FoDReleaseDescriptor.java | 6 +- 22 files changed, 507 insertions(+), 521 deletions(-) delete mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java rename fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/{FoDAttributeDefinitionDescriptor.java => FoDAttributeDescriptor.java} (73%) create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java delete mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java delete mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java index c4b96737522..e3e1258c2e1 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java @@ -22,7 +22,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -40,7 +40,7 @@ public class FoDScanDescriptor extends JsonNodeHolder { private String microserviceName; private String analysisStatusType; private String status; - private ArrayList attributes; + private ArrayList attributes; @JsonIgnore public String getReleaseAndScanId() { @@ -57,7 +57,7 @@ public Map attributesAsMap() { return Collections.emptyMap(); } Map attrMap = new HashMap<>(); - for (FoDAttributeValueDescriptor attr : attributes) { + for (FoDAttributeDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return Collections.unmodifiableMap(attrMap); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java index 67ee8439d2f..aecec5e3595 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java @@ -32,7 +32,7 @@ import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.app.helper.FoDAppUpdateRequest; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -60,7 +60,7 @@ public class FoDAppUpdateCommand extends AbstractFoDJsonNodeOutputCommand implem public JsonNode getJsonNode(UnirestInstance unirest) { FoDAppDescriptor appDescriptor = FoDAppHelper.getAppDescriptor(unirest, appResolver.getAppNameOrId(), true); FoDCriticalityTypeOptions.FoDCriticalityType appCriticalityNew = criticalityTypeUpdate.getCriticalityType(); - JsonNode jsonAttrs = new FoDAttributeDefinitionHelper(unirest).buildAttributesNodeForUpdate(FoDEnums.AttributeTypes.Application, + JsonNode jsonAttrs = FoDAttributeHelper.getAttributesNodeForUpdate(unirest, FoDEnums.AttributeTypes.Application, appDescriptor.getAttributes(), appAttrsUpdate.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs()); String appEmailListNew = FoDAppHelper.getEmailList(notificationsUpdate); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java index 96c0432bb9d..32af2af3acc 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java @@ -34,7 +34,7 @@ import com.fortify.cli.fod.app.cli.mixin.FoDAppTypeOptions.FoDAppType; import com.fortify.cli.fod.app.cli.mixin.FoDCriticalityTypeOptions.FoDCriticalityType; import com.fortify.cli.fod.app.cli.mixin.FoDSdlcStatusTypeOptions.FoDSdlcStatusType; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import com.fortify.cli.fod.release.helper.FoDQualifiedReleaseNameDescriptor; import kong.unirest.UnirestInstance; @@ -111,7 +111,7 @@ public FoDAppCreateRequestBuilder appType(FoDAppType appType) { } public FoDAppCreateRequestBuilder autoAttributes(UnirestInstance unirest, Map attributes, boolean autoRequiredAttrs) { - return attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.All, attributes, autoRequiredAttrs)); + return attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.All, attributes, autoRequiredAttrs)); } public FoDAppCreateRequestBuilder businessCriticality(FoDCriticalityType businessCriticalityType) { diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java index 3c5ea585143..1d3d19b3f87 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java @@ -18,7 +18,7 @@ import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -31,13 +31,13 @@ public class FoDAppDescriptor extends JsonNodeHolder { private String applicationName; private String applicationDescription; private String businessCriticalityType; - private ArrayList attributes; + private ArrayList attributes; private String emailList; private boolean hasMicroservices; public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeValueDescriptor attr : attributes) { + for (FoDAttributeDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java index 07325444a41..d4bf3b50b25 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java @@ -22,7 +22,7 @@ import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeOptionCandidates; import com.fortify.cli.fod.attribute.helper.FoDAttributeCreateRequest; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import com.fortify.cli.fod.attribute.helper.FoDPicklistSortedValue; import kong.unirest.UnirestInstance; @@ -70,7 +70,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { .isRestricted(isRestricted) .picklistValues(getPicklistValues()) .build(); - return new FoDAttributeDefinitionHelper(unirest).createDefinition(attributeCreateRequest).asJsonNode(); + return FoDAttributeHelper.createAttribute(unirest, attributeCreateRequest).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java index 7a049c96488..9c1fa7940b0 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java @@ -18,8 +18,8 @@ import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod._common.rest.FoDUrls; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeResolverMixin; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -33,7 +33,7 @@ public class FoDAttributeDeleteCommand extends AbstractFoDJsonNodeOutputCommand @Override public JsonNode getJsonNode (UnirestInstance unirest){ - FoDAttributeDefinitionDescriptor attrDescriptor = new FoDAttributeDefinitionHelper(unirest).getDefinition(attributeResolver.getAttributeId(), true); + FoDAttributeDescriptor attrDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attributeResolver.getAttributeId(), true); unirest.delete(FoDUrls.ATTRIBUTE) .routeParam("attributeId", String.valueOf(attrDescriptor.getId())) .asObject(JsonNode.class).getBody(); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java index 6d800fee621..319bdd73ccc 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java @@ -15,12 +15,13 @@ import java.util.List; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeResolverMixin; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import com.fortify.cli.fod.attribute.helper.FoDAttributeUpdateRequest; import kong.unirest.UnirestInstance; @@ -34,6 +35,7 @@ public class FoDAttributeUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.Update outputHelper; @Mixin private FoDAttributeResolverMixin.PositionalParameter attributeResolver; + private final ObjectMapper objectMapper = new ObjectMapper(); @Option(names = {"--required"}) private Boolean isRequired; @@ -51,7 +53,7 @@ public class FoDAttributeUpdateCommand extends AbstractFoDJsonNodeOutputCommand public JsonNode getJsonNode(UnirestInstance unirest) { // current values of attribute being updated - FoDAttributeDefinitionDescriptor attrDescriptor = new FoDAttributeDefinitionHelper(unirest).getDefinition(attributeResolver.getAttributeId(), true); + FoDAttributeDescriptor attrDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attributeResolver.getAttributeId(), true); // build request object FoDAttributeUpdateRequest request = FoDAttributeUpdateRequest.builder() @@ -61,7 +63,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { .picklistValues(picklistValues) .build(); - return new FoDAttributeDefinitionHelper(unirest).updateDefinition(String.valueOf(attrDescriptor.getId()), request).asJsonNode(); + return FoDAttributeHelper.updateAttribute(unirest, String.valueOf(attrDescriptor.getId()), request).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java index 7aa9fc8da74..b1df7ae7aa6 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java @@ -17,8 +17,8 @@ import java.util.stream.Stream; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -30,30 +30,29 @@ public class FoDAttributeResolverMixin { public static abstract class AbstractFoDAttributeResolverMixin { public abstract String getAttributeId(); - public FoDAttributeDefinitionDescriptor getAttributeDescriptor(UnirestInstance unirest) { - return new FoDAttributeDefinitionHelper(unirest).getDefinition(getAttributeId(), true); + public FoDAttributeDescriptor getAttributeDescriptor(UnirestInstance unirest) { + return FoDAttributeHelper.getAttributeDescriptor(unirest, getAttributeId(), true); } } public static abstract class AbstractFoDMultiAttributeResolverMixin { public abstract String[] getAttributeIds(); - public FoDAttributeDefinitionDescriptor[] getAttributeDescriptors(UnirestInstance unirest) { - var helper = new FoDAttributeDefinitionHelper(unirest); + public FoDAttributeDescriptor[] getAttributeDescriptors(UnirestInstance unirest) { return Stream.of(getAttributeIds()) - .map(id -> helper.getDefinition(id, true)) - .toArray(FoDAttributeDefinitionDescriptor[]::new); + .map(id -> FoDAttributeHelper.getAttributeDescriptor(unirest, id, true)) + .toArray(FoDAttributeDescriptor[]::new); } public Collection getAttributeDescriptorJsonNodes(UnirestInstance unirest) { return Stream.of(getAttributeDescriptors(unirest)) - .map(FoDAttributeDefinitionDescriptor::asJsonNode) + .map(FoDAttributeDescriptor::asJsonNode) .collect(Collectors.toList()); } public Integer[] getAttributeIds(UnirestInstance unirest) { return Stream.of(getAttributeDescriptors(unirest)) - .map(FoDAttributeDefinitionDescriptor::getId) + .map(FoDAttributeDescriptor::getId) .toArray(Integer[]::new); } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java deleted file mode 100644 index c0ec7164fa7..00000000000 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.fod.attribute.helper; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.rest.unirest.HttpHeader; -import com.fortify.cli.fod._common.rest.FoDUrls; -import com.fortify.cli.fod._common.rest.helper.FoDDataHelper; -import com.fortify.cli.fod._common.util.FoDEnums; - -import kong.unirest.UnirestInstance; -import lombok.Getter; - -/** - * Instance-based helper for FoD attribute definition operations. Lazily loads all attribute - * definitions on first use and caches them for the lifetime of this instance. Intended to be - * instantiated once per command execution; never stored statically. - */ -public class FoDAttributeDefinitionHelper { - private static final Logger LOG = LoggerFactory.getLogger(FoDAttributeDefinitionHelper.class); - private final UnirestInstance unirest; - - @Getter(lazy = true) - private final List allDefinitions = loadAllDefinitions(); - - public FoDAttributeDefinitionHelper(UnirestInstance unirest) { - this.unirest = unirest; - } - - private List loadAllDefinitions() { - var body = unirest.get(FoDUrls.ATTRIBUTES).asObject(ObjectNode.class).getBody(); - var items = body.get("items"); - if (items == null || !items.isArray()) { return Collections.emptyList(); } - List result = new ArrayList<>(); - for (var item : items) { - var def = JsonHelper.treeToValue(item, FoDAttributeDefinitionDescriptor.class); - if (def != null) { result.add(def); } - } - return Collections.unmodifiableList(result); - } - - /** - * Looks up an attribute definition by name or numeric id, searching the lazy-loaded list. - */ - public FoDAttributeDefinitionDescriptor getDefinition(String nameOrId, boolean failIfNotFound) { - if (nameOrId == null) { - if (failIfNotFound) { throw new FcliSimpleException("No attribute found for name or id: null"); } - return null; - } - var definitions = getAllDefinitions(); - FoDAttributeDefinitionDescriptor found; - try { - int id = Integer.parseInt(nameOrId); - found = definitions.stream().filter(d -> Objects.equals(d.getId(), id)).findFirst().orElse(null); - } catch (NumberFormatException nfe) { - String trimmed = nameOrId.trim(); - found = definitions.stream().filter(d -> trimmed.equalsIgnoreCase(d.getName())).findFirst().orElse(null); - } - if (found == null && failIfNotFound) { - throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); - } - return found; - } - - /** - * Returns a map of required-attribute names to their default values, filtered to the given type. - */ - public Map getRequiredDefaultValues(FoDEnums.AttributeTypes attrType) { - Map result = new HashMap<>(); - for (var def : getAllDefinitions()) { - if (def.getIsRequired() && (attrType.getValue() == 0 || Objects.equals(def.getAttributeTypeId(), attrType.getValue()))) { - var defaultValue = getDefaultValue(def); - if (defaultValue != null) { result.put(def.getName(), defaultValue); } - } - } - return result; - } - - /** - * Builds an attribute ArrayNode for create operations. Resolves names to ids, filters by - * attrType, and optionally adds defaults for any required attributes not already specified. - */ - public JsonNode buildAttributesNode(FoDEnums.AttributeTypes attrType, Map attributesMap, boolean autoReqdAttributes) { - var effectiveMap = buildEffectiveAttributeUpdates(attrType, null, attributesMap, autoReqdAttributes); - ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - for (var entry : effectiveMap.entrySet()) { - var def = getDefinition(entry.getKey(), true); - if (attrType.getValue() == 0 || Objects.equals(def.getAttributeTypeId(), attrType.getValue())) { - ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); - attrObj.put("id", def.getId()); - attrObj.put("value", entry.getValue()); - attrArray.add(attrObj); - } else { - LOG.debug("Skipping attribute '{}' as it is not a {} attribute", def.getName(), attrType); - } - } - return attrArray; - } - - /** - * Builds an attribute ArrayNode for update operations. Merges current entity attribute values - * with user-supplied updates, optionally filling in defaults for required attributes. - */ - public JsonNode buildAttributesNodeForUpdate(FoDEnums.AttributeTypes attrType, - ArrayList currentAttributes, Map userSuppliedUpdates, - boolean autoReqdAttributes) { - var effectiveUpdates = buildEffectiveAttributeUpdates(attrType, currentAttributes, userSuppliedUpdates, autoReqdAttributes); - return effectiveUpdates.isEmpty() - ? attributeValuesToNode(currentAttributes) - : mergeAttributesNode(currentAttributes, effectiveUpdates); - } - - /** - * Merges current entity attribute values with a user-supplied name→value map. Resolves attribute - * names to IDs. Attributes already on the entity are updated; new attributes are appended. - */ - public JsonNode mergeAttributesNode(ArrayList current, Map updates) { - ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - if (updates == null || updates.isEmpty()) { return attrArray; } - - Map updatesWithId = new HashMap<>(); - for (var entry : updates.entrySet()) { - var def = getDefinition(entry.getKey(), true); - updatesWithId.put(def.getId(), entry.getValue()); - } - - Set processedIds = new HashSet<>(); - if (current != null) { - for (var attr : current) { - int id = attr.getId(); - ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); - attrObj.put("id", id); - attrObj.put("value", updatesWithId.getOrDefault(id, attr.getValue())); - attrArray.add(attrObj); - processedIds.add(id); - } - } - for (var entry : updatesWithId.entrySet()) { - if (!processedIds.contains(entry.getKey())) { - ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); - attrObj.put("id", entry.getKey()); - attrObj.put("value", entry.getValue()); - attrArray.add(attrObj); - } - } - return attrArray; - } - - /** - * Pure serialization helper: converts a list of entity attribute values to an ArrayNode of - * {id, value} objects without any network calls. Useful when no updates are needed. - */ - public static JsonNode attributeValuesToNode(ArrayList values) { - ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - if (values == null || values.isEmpty()) { return attrArray; } - for (var attr : values) { - ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); - attrObj.put("id", attr.getId()); - attrObj.put("value", attr.getValue()); - attrArray.add(attrObj); - } - return attrArray; - } - - /** - * Creates a new attribute definition. Returns the freshly fetched definition after creation. - */ - public FoDAttributeDefinitionDescriptor createDefinition(FoDAttributeCreateRequest request) { - var response = unirest.post(FoDUrls.ATTRIBUTES) - .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") - .body(request) - .asObject(JsonNode.class) - .getBody(); - if (response.has("success") && response.get("success").asBoolean()) { - if (!response.has("attributeId")) { - throw new FcliSimpleException("Response missing attributeId: " + response.toString()); - } - return fetchFromApi(response.get("attributeId").asText(), true); - } else { - throw new FcliSimpleException("Failed to create attribute: " + response.toString()); - } - } - - /** - * Updates an existing attribute definition. Returns the freshly fetched definition after update. - */ - public FoDAttributeDefinitionDescriptor updateDefinition(String attributeId, FoDAttributeUpdateRequest request) { - var response = unirest.put(FoDUrls.ATTRIBUTE) - .routeParam("attributeId", attributeId) - .body(request) - .asObject(JsonNode.class) - .getBody(); - if (response.has("success") && response.get("success").asBoolean()) { - return fetchFromApi(attributeId, true); - } else { - throw new FcliSimpleException("Failed to update attribute: " + response.toString()); - } - } - - /** - * Fetches a single attribute definition directly from the API, bypassing the lazy-loaded cache. - * Used after create/update operations where the cached list may be stale. - */ - private FoDAttributeDefinitionDescriptor fetchFromApi(String nameOrId, boolean failIfNotFound) { - var request = unirest.get(FoDUrls.ATTRIBUTES); - JsonNode result; - try { - int id = Integer.parseInt(nameOrId); - result = FoDDataHelper.findUnique(request, String.format("id:%d", id)); - } catch (NumberFormatException nfe) { - result = FoDDataHelper.findUnique(request, String.format("name:%s", nameOrId)); - } - if (result == null && failIfNotFound) { - throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); - } - return result == null ? null : JsonHelper.treeToValue(result, FoDAttributeDefinitionDescriptor.class); - } - - private Map buildEffectiveAttributeUpdates(FoDEnums.AttributeTypes attrType, - ArrayList currentAttributes, - Map userSuppliedUpdates, boolean autoReqdAttributes) { - var effective = new LinkedHashMap(); - if (autoReqdAttributes) { - Set covered = new HashSet<>(); - if (currentAttributes != null) { - currentAttributes.stream() - .filter(a -> StringUtils.isNotBlank(a.getValue())) - .map(FoDAttributeValueDescriptor::getName) - .forEach(covered::add); - } - if (userSuppliedUpdates != null) { covered.addAll(userSuppliedUpdates.keySet()); } - getRequiredDefaultValues(attrType).forEach((k, v) -> { if (!covered.contains(k)) effective.put(k, v); }); - } - if (userSuppliedUpdates != null) { effective.putAll(userSuppliedUpdates); } - return effective; - } - - private String getDefaultValue(FoDAttributeDefinitionDescriptor def) { - if (StringUtils.isNotBlank(def.getDefaultValue())) { return def.getDefaultValue(); } - return switch (def.getAttributeDataType()) { - case "Text" -> "autofilled by fcli"; - case "Boolean" -> String.valueOf(false); - case "User", "Picklist" -> def.getPicklistValues() != null && !def.getPicklistValues().isEmpty() - ? def.getPicklistValues().get(0).getName() : null; - default -> null; - }; - } -} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java similarity index 73% rename from fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java rename to fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java index 1f20c24319d..450140c0b96 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java @@ -22,15 +22,10 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; -/** - * Describes an FoD attribute definition — the schema record returned by the /api/v3/attributes - * endpoint. Contains metadata (type, data type, picklist values, required/restricted flags) but - * no entity-specific value. For entity attribute values see {@link FoDAttributeValueDescriptor}. - */ @Data @EqualsAndHashCode(callSuper = true) -@Reflectable @NoArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class FoDAttributeDefinitionDescriptor extends JsonNodeHolder { +@Reflectable @NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) // Fix for FoD 26.2+ where the API returns additional fields that are not mapped to this class (e.g. "isMultiSelect") +public class FoDAttributeDescriptor extends JsonNodeHolder { private Integer id; private String name; private Integer attributeTypeId; @@ -40,5 +35,6 @@ public class FoDAttributeDefinitionDescriptor extends JsonNodeHolder { private Boolean isRequired; private Boolean isRestricted; private ArrayList picklistValues; + private String value; private String defaultValue; } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java new file mode 100644 index 00000000000..7b9c3cbba9a --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java @@ -0,0 +1,248 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.attribute.helper; + +import java.util.*; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.rest.unirest.HttpHeader; +import com.fortify.cli.fod._common.rest.FoDUrls; +import com.fortify.cli.fod._common.rest.helper.FoDDataHelper; +import com.fortify.cli.fod._common.util.FoDEnums; + +import kong.unirest.GetRequest; +import kong.unirest.UnirestInstance; +import lombok.Getter; +import lombok.SneakyThrows; + +public class FoDAttributeHelper { + private static final Logger LOG = LoggerFactory.getLogger(FoDAttributeHelper.class); + @Getter private static ObjectMapper objectMapper = new ObjectMapper(); + + public static final FoDAttributeDescriptor getAttributeDescriptor(UnirestInstance unirestInstance, String attrNameOrId, boolean failIfNotFound) { + GetRequest request = unirestInstance.get(FoDUrls.ATTRIBUTES); + JsonNode result = null; + try { + int attrId = Integer.parseInt(attrNameOrId); + result = FoDDataHelper.findUnique(request, String.format("id:%d", attrId)); + } catch (NumberFormatException nfe) { + result = FoDDataHelper.findUnique(request, String.format("name:%s", attrNameOrId)); + } + if ( failIfNotFound && result==null ) { + throw new FcliSimpleException("No attribute found for name or id: " + attrNameOrId); + } + return result==null ? null : JsonHelper.treeToValue(result, FoDAttributeDescriptor.class); + } + + @SneakyThrows + public static final Map getRequiredAttributesDefaultValues(UnirestInstance unirestInstance, + FoDEnums.AttributeTypes attrType) { + Map reqAttrs = new HashMap<>(); + GetRequest request = unirestInstance.get(FoDUrls.ATTRIBUTES) + .queryString("filters", "isRequired:true"); + JsonNode items = request.asObject(ObjectNode.class).getBody().get("items"); + List lookupList = objectMapper.readValue(objectMapper.writeValueAsString(items), + new TypeReference>() { + }); + Iterator lookupIterator = lookupList.iterator(); + while (lookupIterator.hasNext()) { + FoDAttributeDescriptor currentLookup = lookupIterator.next(); + // currentLookup.getAttributeTypeId() == 1 if "Application", 4 if "Release" - filter above does not support querying on this yet! + if (currentLookup.getIsRequired() && (attrType.getValue() == 0 || currentLookup.getAttributeTypeId() == attrType.getValue())) { + var defaultValue = getDefaultValue(currentLookup); + if (defaultValue != null) { + reqAttrs.put(currentLookup.getName(), defaultValue); + } + } + } + return reqAttrs; + } + + public static JsonNode mergeAttributesNode(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, + ArrayList current, + Map updates) { + ArrayNode attrArray = objectMapper.createArrayNode(); + if (updates == null || updates.isEmpty()) return attrArray; + + // Map attribute id to value from updates + Map updatesWithId = new HashMap<>(); + for (Map.Entry attr : updates.entrySet()) { + FoDAttributeDescriptor desc = getAttributeDescriptor(unirest, attr.getKey(), true); + updatesWithId.put(desc.getId(), attr.getValue()); + } + + // Track which ids have been processed + Set processedIds = new HashSet<>(); + + // Add current attributes, updating values if present in updates + if (current != null) { + for (FoDAttributeDescriptor attr : current) { + int id = attr.getId(); + ObjectNode attrObj = objectMapper.createObjectNode(); + attrObj.put("id", id); + attrObj.put("value", updatesWithId.getOrDefault(id, attr.getValue())); + attrArray.add(attrObj); + processedIds.add(id); + } + } + + // Add new attributes from updates not already in current + for (Map.Entry entry : updatesWithId.entrySet()) { + if (!processedIds.contains(entry.getKey())) { + ObjectNode attrObj = objectMapper.createObjectNode(); + attrObj.put("id", entry.getKey()); + attrObj.put("value", entry.getValue()); + attrArray.add(attrObj); + } + } + + return attrArray; + } + + public static JsonNode getAttributesNode(FoDEnums.AttributeTypes attrType, ArrayList attributes) { + ArrayNode attrArray = objectMapper.createArrayNode(); + if (attributes == null || attributes.isEmpty()) return attrArray; + for (FoDAttributeDescriptor attr : attributes) { + ObjectNode attrObj = objectMapper.createObjectNode(); + attrObj.put("id", attr.getId()); + attrObj.put("value", attr.getValue()); + attrArray.add(attrObj); + } + return attrArray; + } + + /** + * For create commands: amends user-provided attribute values with server-side defaults + * for any required attributes not already specified by the user. + * Resolves attribute names to IDs and filters by attrType. + */ + public static JsonNode getAttributesNode(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, + Map attributesMap, boolean autoReqdAttributes) { + var effectiveMap = buildEffectiveAttributeUpdates(unirest, attrType, null, attributesMap, autoReqdAttributes); + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + for (Map.Entry attr : effectiveMap.entrySet()) { + ObjectNode attrObj = getObjectMapper().createObjectNode(); + FoDAttributeDescriptor attributeDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attr.getKey(), true); + // filter out any attributes that aren't valid for the entity we are working on, e.g. Application or Release + if (attrType.getValue() == 0 || attributeDescriptor.getAttributeTypeId() == attrType.getValue()) { + attrObj.put("id", attributeDescriptor.getId()); + attrObj.put("value", attr.getValue()); + attrArray.add(attrObj); + } else { + LOG.debug("Skipping attribute '"+attributeDescriptor.getName()+"' as it is not a "+attrType.toString()+" attribute"); + } + } + return attrArray; + } + + /** + * For update commands: merges user-supplied attribute values with the entity's existing + * attribute values, then amends with server-side defaults for any required attributes + * not already covered by either the current values or user-supplied updates. + */ + public static JsonNode getAttributesNodeForUpdate(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, + ArrayList currentAttributes, Map userSuppliedUpdates, + boolean autoReqdAttributes) { + var effectiveUpdates = buildEffectiveAttributeUpdates( + unirest, attrType, currentAttributes, userSuppliedUpdates, autoReqdAttributes); + return effectiveUpdates.isEmpty() + ? getAttributesNode(attrType, currentAttributes) + : mergeAttributesNode(unirest, attrType, currentAttributes, effectiveUpdates); + } + + /** + * Computes the effective attribute updates to apply. For required attributes not already + * covered by current entity values or user-supplied updates, server-side defaults are added. + * User-supplied updates always take highest priority. + * Pass {@code currentAttributes=null} for create scenarios. + */ + private static Map buildEffectiveAttributeUpdates(UnirestInstance unirest, + FoDEnums.AttributeTypes attrType, ArrayList currentAttributes, + Map userSuppliedUpdates, boolean autoReqdAttributes) { + var effective = new LinkedHashMap(); + if (autoReqdAttributes) { + Set covered = new HashSet<>(); + if (currentAttributes != null) { + currentAttributes.stream() + .filter(a -> StringUtils.isNotBlank(a.getValue())) + .map(FoDAttributeDescriptor::getName) + .forEach(covered::add); + } + if (userSuppliedUpdates != null) { + covered.addAll(userSuppliedUpdates.keySet()); + } + getRequiredAttributesDefaultValues(unirest, attrType) + .forEach((k, v) -> { if (!covered.contains(k)) effective.put(k, v); }); + } + if (userSuppliedUpdates != null) { + effective.putAll(userSuppliedUpdates); + } + return effective; + } + + public static FoDAttributeDescriptor createAttribute(UnirestInstance unirest, FoDAttributeCreateRequest request) { + var response = unirest.post(FoDUrls.ATTRIBUTES) + // Use headerReplace to replace rather than add the Content-Type header (avoid duplicates with defaults) + .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") + .body(request) + .asObject(JsonNode.class) + .getBody(); + if (response.has("success") && response.get("success").asBoolean()) { + if (!response.has("attributeId")) { + throw new FcliSimpleException("Response missing attributeId: " + response.toString()); + } + var attributeId = response.get("attributeId").asText(); + return getAttributeDescriptor(unirest, attributeId, true); + } else { + throw new FcliSimpleException("Failed to create attribute: " + response.toString()); + } + + } + + public static FoDAttributeDescriptor updateAttribute(UnirestInstance unirest, String attributeId, FoDAttributeUpdateRequest request) { + var response = unirest.put(FoDUrls.ATTRIBUTE) + .routeParam("attributeId", attributeId) + .body(request) + .asObject(JsonNode.class) + .getBody(); + if (response.has("success") && response.get("success").asBoolean()) { + return getAttributeDescriptor(unirest, attributeId, true); + } else { + throw new FcliSimpleException("Failed to update attribute: " + response.toString()); + } + + } + + private static String getDefaultValue(FoDAttributeDescriptor attribute) { + if (StringUtils.isNotBlank(attribute.getDefaultValue())) { + return attribute.getDefaultValue(); + } + return switch (attribute.getAttributeDataType()) { + case "Text" -> "autofilled by fcli"; + case "Boolean" -> String.valueOf(false); + case "User", "Picklist" -> attribute.getPicklistValues() != null && !attribute.getPicklistValues().isEmpty() + ? attribute.getPicklistValues().get(0).getName() : null; + default -> null; + }; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java deleted file mode 100644 index 21c2c8c5d29..00000000000 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.fod.attribute.helper; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.formkiq.graalvm.annotations.Reflectable; -import com.fortify.cli.common.json.JsonNodeHolder; - -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -/** - * Represents an attribute value attached to a concrete FoD entity (application, release, - * microservice, scan, issue). Contains only the id, name, and current value — as returned - * by entity endpoints. For the full attribute schema see {@link FoDAttributeDefinitionDescriptor}. - */ -@Data @EqualsAndHashCode(callSuper = true) -@Reflectable @NoArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class FoDAttributeValueDescriptor extends JsonNodeHolder { - private Integer id; - private String name; - private String value; -} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java index b635561c23a..55075a320a6 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java @@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.mcp.MCPInclude; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -30,7 +31,6 @@ import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; import com.fortify.cli.fod.issue.helper.FoDBulkIssueUpdateRequest; import com.fortify.cli.fod.issue.helper.FoDBulkIssueUpdateResponse; -import com.fortify.cli.fod.issue.helper.FoDIssueAttributeHelper; import com.fortify.cli.fod.issue.helper.FoDIssueHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; @@ -54,6 +54,7 @@ public class FoDIssueUpdateCommand extends AbstractFoDJsonNodeOutputCommand impl @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; @Mixin private FoDAttributeUpdateOptions.OptionalAttrOption issueAttrsUpdate; + private final ObjectMapper objectMapper = new ObjectMapper(); @Option(names = {"--user"}, required = true) protected String user; @@ -71,7 +72,6 @@ public class FoDIssueUpdateCommand extends AbstractFoDJsonNodeOutputCommand impl @Override public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); - var issueAttrHelper = new FoDIssueAttributeHelper(unirest); // If vulnIds are provided, filter them against the release vulnerabilities using a helper. int issueUpdateCount = 0; @@ -90,10 +90,10 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } Map attributeUpdates = issueAttrsUpdate.getAttributes(); - JsonNode jsonAttrs = issueAttrHelper.buildAttributesNode(attributeUpdates); + JsonNode jsonAttrs = FoDIssueHelper.buildIssueAttributesNode(unirest, attributeUpdates); // Validate auditor and developer status values against attribute picklists - ResolvedStatuses resolvedStatuses = resolveStatuses(issueAttrHelper); + ResolvedStatuses resolvedStatuses = resolveStatuses(unirest); FoDBulkIssueUpdateRequest issueUpdateRequest = buildIssueUpdateRequest(unirest, resolvedStatuses.developerStatusValue(), resolvedStatuses.auditorStatusValue(), jsonAttrs); FoDBulkIssueUpdateResponse resp = performUpdate(unirest, releaseDescriptor.getReleaseId(), issueUpdateRequest, totalCount, skippedCount, issueUpdateCount); @@ -106,16 +106,16 @@ public JsonNode getJsonNode(UnirestInstance unirest) { private record ResolvedStatuses(String developerStatusValue, String auditorStatusValue) {} - private ResolvedStatuses resolveStatuses(FoDIssueAttributeHelper issueAttrHelper) { + private ResolvedStatuses resolveStatuses(UnirestInstance unirest) { String auditorStatusValue = null; if ( auditorStatus != null && !auditorStatus.isBlank() ) { - auditorStatusValue = issueAttrHelper.resolveStatusValue(auditorStatus, new String[]{ + auditorStatusValue = FoDIssueHelper.resolveStatusValue(unirest, auditorStatus, new String[]{ "Auditor Status (Non suppressed)", "Auditor Status (Suppressed)" }, "auditor-status", AuditorStatusType.values()); } String developerStatusValue = null; if ( developerStatus != null && !developerStatus.isBlank() ) { - developerStatusValue = issueAttrHelper.resolveStatusValue(developerStatus, new String[]{ + developerStatusValue = FoDIssueHelper.resolveStatusValue(unirest, developerStatus, new String[]{ "Developer Status (Open)", "Developer Status (Closed)" }, "developer-status", DeveloperStatusType.values()); } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java deleted file mode 100644 index af894a08d36..00000000000 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.fod.issue.helper; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.fod._common.util.FoDEnums; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; - -import kong.unirest.UnirestInstance; - -/** - * Instance-based helper for FoD issue attribute operations. Delegates definition lookups to - * {@link FoDAttributeDefinitionHelper} and provides issue-specific attribute node building and - * status value resolution. Accepts a caller-supplied definition helper to avoid redundant API - * calls when the caller already holds one, or creates its own from a {@link UnirestInstance}. - * Intended to be instantiated once per command execution; never stored statically. - */ -public class FoDIssueAttributeHelper { - private static final Logger LOG = LoggerFactory.getLogger(FoDIssueAttributeHelper.class); - private final FoDAttributeDefinitionHelper definitionHelper; - - public FoDIssueAttributeHelper(UnirestInstance unirest) { - this(new FoDAttributeDefinitionHelper(unirest)); - } - - public FoDIssueAttributeHelper(FoDAttributeDefinitionHelper definitionHelper) { - this.definitionHelper = definitionHelper; - } - - /** - * Builds an ArrayNode of {id, value} objects for issue attribute updates, filtering to - * Issue-scoped attributes only. - */ - public ArrayNode buildAttributesNode(Map attributeUpdates) { - ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - if (attributeUpdates == null || attributeUpdates.isEmpty()) { return attrArray; } - for (var entry : attributeUpdates.entrySet()) { - var def = definitionHelper.getDefinition(entry.getKey(), false); - if (def == null) { - LOG.warn("Attribute '{}' not found, skipping", entry.getKey()); - continue; - } - if (Objects.equals(def.getAttributeTypeId(), FoDEnums.AttributeTypes.Issue.getValue())) { - var obj = JsonHelper.getObjectMapper().createObjectNode(); - obj.put("id", def.getId()); - obj.put("value", entry.getValue()); - attrArray.add(obj); - } else { - LOG.debug("Skipping attribute '{}' as it is not an Issue attribute", def.getName()); - } - } - return attrArray; - } - - /** - * Resolves a developer/auditor status value against attribute picklists, inferring the - * relevant enum type from the option name. - */ - public String resolveStatusValue(String providedValue, String[] attributeNames, String optionName) { - if (optionName != null && optionName.toLowerCase().contains("developer")) { - return resolveStatusValue(providedValue, attributeNames, optionName, FoDEnums.DeveloperStatusType.values()); - } else if (optionName != null && optionName.toLowerCase().contains("auditor")) { - return resolveStatusValue(providedValue, attributeNames, optionName, FoDEnums.AuditorStatusType.values()); - } - return resolveStatusValue(providedValue, attributeNames, optionName, (FoDEnums.DeveloperStatusType[]) null); - } - - /** - * Resolves a status value against the given enum values first, then against attribute picklists. - * Throws a {@link FcliSimpleException} listing allowed values if resolution fails. - */ - public & FoDEnums.IFoDEnumValueSupplier> String resolveStatusValue( - String providedValue, String[] attributeNames, String optionName, T[] enumValues) { - if (providedValue == null || providedValue.isBlank()) { return null; } - String originalProvided = providedValue; - String candidate = providedValue.trim(); - try { - if (enumValues != null) { - var resolved = FoDEnums.IFoDEnumValueSupplier.resolveEnumValue(candidate, enumValues); - if (resolved.isPresent()) { candidate = resolved.get(); } - } - } catch (Exception e) { - LOG.debug("Error resolving enum-style status value for {}: {}", optionName, e.getMessage()); - } - - String attrResolved = tryResolveAgainstAttributes(attributeNames, candidate); - if (attrResolved != null) { return attrResolved; } - - var allowed = collectAllowedAttributeValues(attributeNames); - throw new FcliSimpleException(String.format("Invalid %s '%s'. Allowed values: %s", - optionName, originalProvided, String.join(", ", allowed))); - } - - private String tryResolveAgainstAttributes(String[] attributeNames, String candidate) { - for (String attrName : attributeNames) { - var def = definitionHelper.getDefinition(attrName, false); - if (def == null) { continue; } - var picklist = def.getPicklistValues(); - if (picklist == null || picklist.isEmpty()) { continue; } - for (var pv : picklist) { - if (pv.getName() != null && pv.getName().equalsIgnoreCase(candidate)) { return pv.getName(); } - } - try { - int providedId = Integer.parseInt(candidate); - for (var pv : picklist) { - if (Objects.equals(pv.getId(), providedId)) { return pv.getName(); } - } - } catch (NumberFormatException ignored) {} - } - return null; - } - - private List collectAllowedAttributeValues(String[] attributeNames) { - var allowed = new ArrayList(); - for (String attrName : attributeNames) { - var def = definitionHelper.getDefinition(attrName, false); - if (def == null) { continue; } - var picklist = def.getPicklistValues(); - if (picklist == null) { continue; } - for (var pv : picklist) { allowed.add(pv.getName()); } - } - return allowed; - } -} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java index 5a355d9534f..e6e4a906f8b 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java @@ -19,11 +19,18 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.json.transform.fields.RenameFieldsTransformer; @@ -31,13 +38,97 @@ import com.fortify.cli.fod._common.rest.helper.FoDInputTransformer; import com.fortify.cli.fod._common.rest.helper.FoDPagingHelper; import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; +import com.fortify.cli.fod._common.util.FoDEnums; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import kong.unirest.HttpResponse; import kong.unirest.UnirestInstance; import lombok.Builder; import lombok.Data; +import lombok.Getter; public class FoDIssueHelper { + private static final Logger LOG = LoggerFactory.getLogger(FoDIssueHelper.class); + @Getter private static ObjectMapper objectMapper = new ObjectMapper(); + + // Local cache for attribute descriptors used during bulk issue updates. Populated by loadAllAttributes(). + private static final ConcurrentHashMap ATTR_CACHE_BY_NAME = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap ATTR_CACHE_BY_ID = new ConcurrentHashMap<>(); + private static volatile boolean attributesPrefetched = false; + + /** + * Prefetch all attributes from FoD and populate the local cache. Safe to call multiple times; will perform + * the fetch only once per JVM unless clearAttributesCache() is called. + */ + public static synchronized void loadAllAttributes(UnirestInstance unirest) { + if ( attributesPrefetched ) return; + // Use local temporary maps to build the cache so a failed fetch/parse doesn't leave partial data + var tmpById = new HashMap(); + var tmpByName = new HashMap(); + try { + var request = unirest.get(FoDUrls.ATTRIBUTES); + var body = request.asObject(ObjectNode.class).getBody(); + var items = body.get("items"); + if ( items!=null && items.isArray() ) { + for (var item : items) { + FoDAttributeDescriptor desc = JsonHelper.treeToValue(item, FoDAttributeDescriptor.class); + if ( desc!=null ) { + tmpById.putIfAbsent(desc.getId(), desc); + if ( desc.getName()!=null ) { + tmpByName.putIfAbsent(desc.getName(), desc); + tmpByName.putIfAbsent(desc.getName().trim(), desc); + } + } + } + } + // Merge into the concurrent caches; preserve any existing descriptors using putIfAbsent + for ( var e : tmpById.entrySet() ) { + ATTR_CACHE_BY_ID.putIfAbsent(e.getKey(), e.getValue()); + } + for ( var e : tmpByName.entrySet() ) { + ATTR_CACHE_BY_NAME.putIfAbsent(e.getKey(), e.getValue()); + } + attributesPrefetched = true; // set only after successful population + } catch (kong.unirest.UnirestException e) { + throw new FcliTechnicalException("Error loading attribute descriptors", e); + } catch (Exception e) { + throw new FcliTechnicalException("Error processing attribute descriptors", e); + } + } + + public static void clearAttributesCache() { + ATTR_CACHE_BY_ID.clear(); + ATTR_CACHE_BY_NAME.clear(); + attributesPrefetched = false; + } + + /** + * Resolve an attribute descriptor from the local cache. If not prefetched yet, will call loadAllAttributes. + */ + public static FoDAttributeDescriptor getAttributeDescriptorFromCache(UnirestInstance unirest, String nameOrId, boolean failIfNotFound) { + if ( !attributesPrefetched ) { + loadAllAttributes(unirest); + } + if ( nameOrId==null ) { + if ( failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: null"); + return null; + } + try { + int id = Integer.parseInt(nameOrId); + var desc = ATTR_CACHE_BY_ID.get(id); + if ( desc==null && failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); + return desc; + } catch (NumberFormatException nfe) { + var desc = ATTR_CACHE_BY_NAME.get(nameOrId); + if ( desc==null ) { + // try trimmed + desc = ATTR_CACHE_BY_NAME.get(nameOrId.trim()); + } + if ( desc==null && failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); + return desc; + } + } public static final JsonNode transformRecord(JsonNode record) { return new RenameFieldsTransformer(new String[]{}).transform(record); @@ -121,7 +212,7 @@ public static final ObjectNode transformRecord(ObjectNode record, IssueAggregati } public static final FoDBulkIssueUpdateResponse updateIssues(UnirestInstance unirest, String releaseId, FoDBulkIssueUpdateRequest issueUpdateRequest) { - ObjectNode body = JsonHelper.getObjectMapper().valueToTree(issueUpdateRequest); + ObjectNode body = objectMapper.valueToTree(issueUpdateRequest); var result = unirest.post(FoDUrls.VULNERABILITIES + "/bulk-edit") .routeParam("relId", releaseId) .body(body).asObject(JsonNode.class).getBody(); @@ -274,6 +365,80 @@ public static java.util.Set getVulnIdsForRelease(UnirestInstance unirest return result; } + /** + * Resolve a status value (developer/auditor) against one or more FoD attribute picklists. + * Returns the canonical picklist name when found, or throws a FcliSimpleException listing allowed values. + */ + public static String resolveStatusValue(UnirestInstance unirest, String providedValue, String[] attributeNames, String optionName) { + // Maintain compatibility by delegating to the generic overload; try to infer enum when optionName indicates developer/auditor + FoDEnums.DeveloperStatusType[] devEnum = FoDEnums.DeveloperStatusType.values(); + FoDEnums.AuditorStatusType[] audEnum = FoDEnums.AuditorStatusType.values(); + if ( optionName!=null && optionName.toLowerCase().contains("developer") ) { + return resolveStatusValue(unirest, providedValue, attributeNames, optionName, devEnum); + } else if ( optionName!=null && optionName.toLowerCase().contains("auditor") ) { + return resolveStatusValue(unirest, providedValue, attributeNames, optionName, audEnum); + } + return resolveStatusValue(unirest, providedValue, attributeNames, optionName, (FoDEnums.DeveloperStatusType[])null); + } + + public static & FoDEnums.IFoDEnumValueSupplier> String resolveStatusValue(UnirestInstance unirest, String providedValue, String[] attributeNames, String optionName, T[] enumValues) { + if ( providedValue==null || providedValue.isBlank() ) { return null; } + String originalProvided = providedValue; + String candidate = providedValue.trim(); + try { + if ( enumValues!=null ) { + var resolved = FoDEnums.IFoDEnumValueSupplier.resolveEnumValue(candidate, enumValues); + if ( resolved.isPresent() ) { candidate = resolved.get(); } + } + } catch (Exception e) { + LOG.debug("Error resolving enum-style status value for {}: {}", optionName, e.getMessage()); + } + + String attrResolved = tryResolveAgainstAttributes(unirest, attributeNames, candidate); + if ( attrResolved!=null ) return attrResolved; + + var allowed = collectAllowedAttributeValues(unirest, attributeNames); + throw new FcliSimpleException(String.format("Invalid %s '%s'. Allowed values: %s", optionName, originalProvided, String.join(", ", allowed))); + } + + private static String tryResolveAgainstAttributes(UnirestInstance unirest, String[] attributeNames, String candidate) { + for (String attrName: attributeNames) { + var desc = FoDAttributeHelper.getAttributeDescriptor(unirest, attrName, false); + if ( desc==null ) continue; + var picklist = desc.getPicklistValues(); + if ( picklist==null || picklist.isEmpty() ) continue; + for (var pv : picklist) { + if ( pv.getName()!=null && pv.getName().equalsIgnoreCase(candidate) ) { + return pv.getName(); + } + } + // if provided value looks like an id, try matching by id + try { + int providedId = Integer.parseInt(candidate); + for (var pv : picklist) { + if ( Objects.equals(pv.getId(), providedId) ) { + return pv.getName(); + } + } + } catch (NumberFormatException ignored) {} + } + return null; + } + + private static List collectAllowedAttributeValues(UnirestInstance unirest, String[] attributeNames) { + var allowed = new ArrayList(); + for (String attrName: attributeNames) { + var desc = FoDAttributeHelper.getAttributeDescriptor(unirest, attrName, false); + if ( desc==null ) continue; + var picklist = desc.getPicklistValues(); + if ( picklist==null ) continue; + for (var pv: picklist) { + allowed.add(pv.getName()); + } + } + return allowed; + } + /** * Result carrier for vuln filtering: kept (normalized ids to update), skipped (original values skipped), totalCount */ @@ -325,4 +490,30 @@ private static String normalizeVulnId(String id) { } return v.isEmpty() ? null : v; } + + /** + * Build an ArrayNode of attribute objects (id/value) for Issue attribute updates using the localized + * attribute cache. Will prefetch attributes if necessary. + */ + public static ArrayNode buildIssueAttributesNode(UnirestInstance unirest, Map attributeUpdates) { + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + if ( attributeUpdates==null || attributeUpdates.isEmpty() ) return attrArray; + // Ensure local cache populated + loadAllAttributes(unirest); + for ( Map.Entry e : attributeUpdates.entrySet() ) { + String attrName = e.getKey(); + String value = e.getValue(); + FoDAttributeDescriptor attributeDescriptor = getAttributeDescriptorFromCache(unirest, attrName, true); + // Only include attributes that are Issue-scoped (AttributeTypes.Issue) + if ( attributeDescriptor!=null && attributeDescriptor.getAttributeTypeId() == FoDEnums.AttributeTypes.Issue.getValue() ) { + var obj = JsonHelper.getObjectMapper().createObjectNode(); + obj.put("id", attributeDescriptor.getId()); + obj.put("value", value); + attrArray.add(obj); + } else { + LOG.debug("Skipping attribute '{}' as it is not an Issue attribute", attributeDescriptor.getName()); + } + } + return attrArray; + } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java index 1a6f78c5023..6ce2013820e 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java @@ -21,7 +21,7 @@ import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import com.fortify.cli.fod.microservice.cli.mixin.FoDMicroserviceByQualifiedNameResolverMixin; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; @@ -54,7 +54,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { FoDQualifiedMicroserviceNameDescriptor qualifiedMicroserviceNameDescriptor = qualifiedMicroserviceNameResolver.getQualifiedMicroserviceNameDescriptor(); FoDMicroserviceUpdateRequest msCreateRequest = FoDMicroserviceUpdateRequest.builder() .microserviceName(qualifiedMicroserviceNameDescriptor.getMicroserviceName()) - .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Microservice, + .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, msAttrs.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs())) .build(); return FoDMicroserviceHelper.createMicroservice(unirest, appDescriptor, msCreateRequest).asJsonNode(); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java index d60884ffd64..a37ea2d6edc 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java @@ -16,13 +16,15 @@ import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; +import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; -import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import com.fortify.cli.fod.microservice.cli.mixin.FoDMicroserviceByQualifiedNameResolverMixin; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; @@ -37,6 +39,7 @@ @Command(name = OutputHelperMixins.Update.CMD_NAME) public class FoDMicroserviceUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.Update outputHelper; + private final ObjectMapper objectMapper = new ObjectMapper(); @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDMicroserviceByQualifiedNameResolverMixin.PositionalParameter microserviceResolver; @@ -49,18 +52,20 @@ public class FoDMicroserviceUpdateCommand extends AbstractFoDJsonNodeOutputComma @Override public JsonNode getJsonNode(UnirestInstance unirest) { FoDMicroserviceDescriptor msDescriptor = microserviceResolver.getMicroserviceDescriptor(unirest, true); - ArrayList msAttrsCurrent = msDescriptor.getAttributes(); + ArrayList msAttrsCurrent = msDescriptor.getAttributes(); Map attributeUpdates = msAttrsUpdate.getAttributes(); - JsonNode jsonAttrs; + JsonNode jsonAttrs = objectMapper.createArrayNode(); if (attributeUpdates != null && !attributeUpdates.isEmpty()) { - jsonAttrs = new FoDAttributeDefinitionHelper(unirest).mergeAttributesNode(msAttrsCurrent, attributeUpdates); + jsonAttrs = FoDAttributeHelper.mergeAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, + msAttrsCurrent, attributeUpdates); } else { - jsonAttrs = FoDAttributeDefinitionHelper.attributeValuesToNode(msAttrsCurrent); + jsonAttrs = FoDAttributeHelper.getAttributesNode(FoDEnums.AttributeTypes.Microservice, msAttrsCurrent); } + FoDMicroserviceDescriptor appMicroserviceDescriptor = microserviceResolver.getMicroserviceDescriptor(unirest, true); FoDMicroserviceUpdateRequest msUpdateRequest = FoDMicroserviceUpdateRequest.builder() .microserviceName(microserviceName) .attributes(jsonAttrs).build(); - return FoDMicroserviceHelper.updateMicroservice(unirest, msDescriptor, msUpdateRequest).asJsonNode(); + return FoDMicroserviceHelper.updateMicroservice(unirest, appMicroserviceDescriptor, msUpdateRequest).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java index 8e4c3cc1487..d1b2d7c6933 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java @@ -18,7 +18,7 @@ import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -31,11 +31,11 @@ public class FoDMicroserviceDescriptor extends JsonNodeHolder { private String applicationName; private String microserviceId; private String microserviceName; - private ArrayList attributes; + private ArrayList attributes; public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeValueDescriptor attr : attributes) { + for (FoDAttributeDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java index 94962745dc3..23d543530fc 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java @@ -26,7 +26,7 @@ import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import kong.unirest.UnirestInstance; @@ -99,7 +99,7 @@ public static final FoDMicroserviceDescriptor createMicroservice(UnirestInstance boolean autoRequiredAttrs) { var request = FoDMicroserviceUpdateRequest.builder() .microserviceName(microserviceName) - .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Microservice, + .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, msAttrs.getAttributes(), autoRequiredAttrs)) .build(); return createMicroservice(unirest, appDescriptor, request); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java index 1ddcba8553d..50080122c7b 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java @@ -40,7 +40,7 @@ import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; @@ -150,7 +150,7 @@ private final ObjectNode createRelease(UnirestInstance unirest, FoDAppDescriptor .releaseName(simpleReleaseName) .releaseDescription(description) .sdlcStatusType(sdlcStatus.getSdlcStatusType().name()) - .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Release, + .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Release, relAttrs.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs())); requestBuilder = addMicroservice(microserviceDescriptor, requestBuilder); requestBuilder = addCopyFrom(unirest, appDescriptor, requestBuilder); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java index 41d0c73f61f..8649b783452 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java @@ -25,7 +25,7 @@ import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.app.cli.mixin.FoDSdlcStatusTypeOptions; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; import com.fortify.cli.fod.release.helper.FoDReleaseHelper; @@ -63,7 +63,7 @@ public class FoDReleaseUpdateCommand extends AbstractFoDJsonNodeOutputCommand im public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); FoDSdlcStatusTypeOptions.FoDSdlcStatusType sdlcStatusTypeNew = sdlcStatus.getSdlcStatusType(); - JsonNode jsonAttrs = new FoDAttributeDefinitionHelper(unirest).buildAttributesNodeForUpdate(FoDEnums.AttributeTypes.Release, + JsonNode jsonAttrs = FoDAttributeHelper.getAttributesNodeForUpdate(unirest, FoDEnums.AttributeTypes.Release, releaseDescriptor.getAttributes(), appAttrsUpdate.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs()); FoDReleaseUpdateRequest appRelUpdateRequest = FoDReleaseUpdateRequest.builder() diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java index cdb54a3e9d2..fdea71e369d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java @@ -22,7 +22,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -58,7 +58,7 @@ public class FoDReleaseDescriptor extends JsonNodeHolder { private LocalDateTime staticScanDate; private LocalDateTime dynamicScanDate; private LocalDateTime mobileScanDate; - private ArrayList attributes; + private ArrayList attributes; @JsonIgnore public String getQualifiedName() { return StringUtils.isBlank(microserviceName) @@ -74,7 +74,7 @@ public String getQualifierPrefix(String delimiter) { public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeValueDescriptor attr : attributes) { + for (FoDAttributeDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; From 8762f946d494ba8f5667d8728e1a8a60b8fbea14 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 13 May 2026 16:26:24 +0200 Subject: [PATCH 21/55] chore: Refactor FoD attribute handling, remove static caches (re-implement on top of dev/v3.x changes) --- .../scan/helper/FoDScanDescriptor.java | 6 +- .../fod/app/cli/cmd/FoDAppUpdateCommand.java | 4 +- .../fod/app/helper/FoDAppCreateRequest.java | 4 +- .../cli/fod/app/helper/FoDAppDescriptor.java | 6 +- .../cli/cmd/FoDAttributeCreateCommand.java | 4 +- .../cli/cmd/FoDAttributeDeleteCommand.java | 6 +- .../cli/cmd/FoDAttributeUpdateCommand.java | 10 +- .../cli/mixin/FoDAttributeResolverMixin.java | 19 +- ... => FoDAttributeDefinitionDescriptor.java} | 7 +- .../helper/FoDAttributeDefinitionHelper.java | 250 ++++++++++++++++++ .../attribute/helper/FoDAttributeHelper.java | 248 ----------------- .../helper/FoDAttributeValueDescriptor.java | 30 +++ .../issue/cli/cmd/FoDIssueUpdateCommand.java | 17 +- .../issue/helper/FoDIssueAttributeHelper.java | 129 +++++++++ .../cli/fod/issue/helper/FoDIssueHelper.java | 189 +------------ .../cli/cmd/FoDMicroserviceCreateCommand.java | 4 +- .../cli/cmd/FoDMicroserviceUpdateCommand.java | 22 +- .../helper/FoDMicroserviceDescriptor.java | 6 +- .../helper/FoDMicroserviceHelper.java | 4 +- .../cli/cmd/FoDReleaseCreateCommand.java | 4 +- .../cli/cmd/FoDReleaseUpdateCommand.java | 4 +- .../release/helper/FoDReleaseDescriptor.java | 6 +- 22 files changed, 471 insertions(+), 508 deletions(-) rename fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/{FoDAttributeDescriptor.java => FoDAttributeDefinitionDescriptor.java} (81%) create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java delete mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java index e3e1258c2e1..c4b96737522 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanDescriptor.java @@ -22,7 +22,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -40,7 +40,7 @@ public class FoDScanDescriptor extends JsonNodeHolder { private String microserviceName; private String analysisStatusType; private String status; - private ArrayList attributes; + private ArrayList attributes; @JsonIgnore public String getReleaseAndScanId() { @@ -57,7 +57,7 @@ public Map attributesAsMap() { return Collections.emptyMap(); } Map attrMap = new HashMap<>(); - for (FoDAttributeDescriptor attr : attributes) { + for (FoDAttributeValueDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return Collections.unmodifiableMap(attrMap); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java index aecec5e3595..67ee8439d2f 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java @@ -32,7 +32,7 @@ import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.app.helper.FoDAppUpdateRequest; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -60,7 +60,7 @@ public class FoDAppUpdateCommand extends AbstractFoDJsonNodeOutputCommand implem public JsonNode getJsonNode(UnirestInstance unirest) { FoDAppDescriptor appDescriptor = FoDAppHelper.getAppDescriptor(unirest, appResolver.getAppNameOrId(), true); FoDCriticalityTypeOptions.FoDCriticalityType appCriticalityNew = criticalityTypeUpdate.getCriticalityType(); - JsonNode jsonAttrs = FoDAttributeHelper.getAttributesNodeForUpdate(unirest, FoDEnums.AttributeTypes.Application, + JsonNode jsonAttrs = new FoDAttributeDefinitionHelper(unirest).buildAttributesNodeForUpdate(FoDEnums.AttributeTypes.Application, appDescriptor.getAttributes(), appAttrsUpdate.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs()); String appEmailListNew = FoDAppHelper.getEmailList(notificationsUpdate); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java index 32af2af3acc..96c0432bb9d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppCreateRequest.java @@ -34,7 +34,7 @@ import com.fortify.cli.fod.app.cli.mixin.FoDAppTypeOptions.FoDAppType; import com.fortify.cli.fod.app.cli.mixin.FoDCriticalityTypeOptions.FoDCriticalityType; import com.fortify.cli.fod.app.cli.mixin.FoDSdlcStatusTypeOptions.FoDSdlcStatusType; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.release.helper.FoDQualifiedReleaseNameDescriptor; import kong.unirest.UnirestInstance; @@ -111,7 +111,7 @@ public FoDAppCreateRequestBuilder appType(FoDAppType appType) { } public FoDAppCreateRequestBuilder autoAttributes(UnirestInstance unirest, Map attributes, boolean autoRequiredAttrs) { - return attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.All, attributes, autoRequiredAttrs)); + return attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.All, attributes, autoRequiredAttrs)); } public FoDAppCreateRequestBuilder businessCriticality(FoDCriticalityType businessCriticalityType) { diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java index 1d3d19b3f87..3c5ea585143 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/helper/FoDAppDescriptor.java @@ -18,7 +18,7 @@ import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -31,13 +31,13 @@ public class FoDAppDescriptor extends JsonNodeHolder { private String applicationName; private String applicationDescription; private String businessCriticalityType; - private ArrayList attributes; + private ArrayList attributes; private String emailList; private boolean hasMicroservices; public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeDescriptor attr : attributes) { + for (FoDAttributeValueDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java index d4bf3b50b25..07325444a41 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeCreateCommand.java @@ -22,7 +22,7 @@ import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeOptionCandidates; import com.fortify.cli.fod.attribute.helper.FoDAttributeCreateRequest; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.attribute.helper.FoDPicklistSortedValue; import kong.unirest.UnirestInstance; @@ -70,7 +70,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { .isRestricted(isRestricted) .picklistValues(getPicklistValues()) .build(); - return FoDAttributeHelper.createAttribute(unirest, attributeCreateRequest).asJsonNode(); + return new FoDAttributeDefinitionHelper(unirest).createDefinition(attributeCreateRequest).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java index 9c1fa7940b0..7a049c96488 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeDeleteCommand.java @@ -18,8 +18,8 @@ import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod._common.rest.FoDUrls; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeResolverMixin; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -33,7 +33,7 @@ public class FoDAttributeDeleteCommand extends AbstractFoDJsonNodeOutputCommand @Override public JsonNode getJsonNode (UnirestInstance unirest){ - FoDAttributeDescriptor attrDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attributeResolver.getAttributeId(), true); + FoDAttributeDefinitionDescriptor attrDescriptor = new FoDAttributeDefinitionHelper(unirest).getDefinition(attributeResolver.getAttributeId(), true); unirest.delete(FoDUrls.ATTRIBUTE) .routeParam("attributeId", String.valueOf(attrDescriptor.getId())) .asObject(JsonNode.class).getBody(); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java index 319bdd73ccc..6d800fee621 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/cmd/FoDAttributeUpdateCommand.java @@ -15,13 +15,12 @@ import java.util.List; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeResolverMixin; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.attribute.helper.FoDAttributeUpdateRequest; import kong.unirest.UnirestInstance; @@ -35,7 +34,6 @@ public class FoDAttributeUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.Update outputHelper; @Mixin private FoDAttributeResolverMixin.PositionalParameter attributeResolver; - private final ObjectMapper objectMapper = new ObjectMapper(); @Option(names = {"--required"}) private Boolean isRequired; @@ -53,7 +51,7 @@ public class FoDAttributeUpdateCommand extends AbstractFoDJsonNodeOutputCommand public JsonNode getJsonNode(UnirestInstance unirest) { // current values of attribute being updated - FoDAttributeDescriptor attrDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attributeResolver.getAttributeId(), true); + FoDAttributeDefinitionDescriptor attrDescriptor = new FoDAttributeDefinitionHelper(unirest).getDefinition(attributeResolver.getAttributeId(), true); // build request object FoDAttributeUpdateRequest request = FoDAttributeUpdateRequest.builder() @@ -63,7 +61,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { .picklistValues(picklistValues) .build(); - return FoDAttributeHelper.updateAttribute(unirest, String.valueOf(attrDescriptor.getId()), request).asJsonNode(); + return new FoDAttributeDefinitionHelper(unirest).updateDefinition(String.valueOf(attrDescriptor.getId()), request).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java index b1df7ae7aa6..7aa9fc8da74 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/cli/mixin/FoDAttributeResolverMixin.java @@ -17,8 +17,8 @@ import java.util.stream.Stream; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -30,29 +30,30 @@ public class FoDAttributeResolverMixin { public static abstract class AbstractFoDAttributeResolverMixin { public abstract String getAttributeId(); - public FoDAttributeDescriptor getAttributeDescriptor(UnirestInstance unirest) { - return FoDAttributeHelper.getAttributeDescriptor(unirest, getAttributeId(), true); + public FoDAttributeDefinitionDescriptor getAttributeDescriptor(UnirestInstance unirest) { + return new FoDAttributeDefinitionHelper(unirest).getDefinition(getAttributeId(), true); } } public static abstract class AbstractFoDMultiAttributeResolverMixin { public abstract String[] getAttributeIds(); - public FoDAttributeDescriptor[] getAttributeDescriptors(UnirestInstance unirest) { + public FoDAttributeDefinitionDescriptor[] getAttributeDescriptors(UnirestInstance unirest) { + var helper = new FoDAttributeDefinitionHelper(unirest); return Stream.of(getAttributeIds()) - .map(id -> FoDAttributeHelper.getAttributeDescriptor(unirest, id, true)) - .toArray(FoDAttributeDescriptor[]::new); + .map(id -> helper.getDefinition(id, true)) + .toArray(FoDAttributeDefinitionDescriptor[]::new); } public Collection getAttributeDescriptorJsonNodes(UnirestInstance unirest) { return Stream.of(getAttributeDescriptors(unirest)) - .map(FoDAttributeDescriptor::asJsonNode) + .map(FoDAttributeDefinitionDescriptor::asJsonNode) .collect(Collectors.toList()); } public Integer[] getAttributeIds(UnirestInstance unirest) { return Stream.of(getAttributeDescriptors(unirest)) - .map(FoDAttributeDescriptor::getId) + .map(FoDAttributeDefinitionDescriptor::getId) .toArray(Integer[]::new); } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java similarity index 81% rename from fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java rename to fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java index 450140c0b96..8c234d20622 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionDescriptor.java @@ -23,9 +23,9 @@ import lombok.NoArgsConstructor; @Data @EqualsAndHashCode(callSuper = true) -@Reflectable @NoArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) // Fix for FoD 26.2+ where the API returns additional fields that are not mapped to this class (e.g. "isMultiSelect") -public class FoDAttributeDescriptor extends JsonNodeHolder { +@Reflectable @NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class FoDAttributeDefinitionDescriptor extends JsonNodeHolder { private Integer id; private String name; private Integer attributeTypeId; @@ -35,6 +35,5 @@ public class FoDAttributeDescriptor extends JsonNodeHolder { private Boolean isRequired; private Boolean isRestricted; private ArrayList picklistValues; - private String value; private String defaultValue; } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java new file mode 100644 index 00000000000..6fdf63f0505 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java @@ -0,0 +1,250 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.attribute.helper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.rest.unirest.HttpHeader; +import com.fortify.cli.fod._common.rest.FoDUrls; +import com.fortify.cli.fod._common.rest.helper.FoDDataHelper; +import com.fortify.cli.fod._common.util.FoDEnums; + +import kong.unirest.UnirestInstance; +import lombok.Getter; + +public class FoDAttributeDefinitionHelper { + private static final Logger LOG = LoggerFactory.getLogger(FoDAttributeDefinitionHelper.class); + private final UnirestInstance unirest; + @Getter(lazy = true) private final List allDefinitions = loadAllDefinitions(); + + public FoDAttributeDefinitionHelper(UnirestInstance unirest) { + this.unirest = unirest; + } + + private List loadAllDefinitions() { + var result = new ArrayList(); + var body = unirest.get(FoDUrls.ATTRIBUTES).asObject(ObjectNode.class).getBody(); + var items = body.get("items"); + if (items != null && items.isArray()) { + for (var item : items) { + var desc = JsonHelper.treeToValue(item, FoDAttributeDefinitionDescriptor.class); + if (desc != null) { result.add(desc); } + } + } + return result; + } + + public FoDAttributeDefinitionDescriptor getDefinition(String nameOrId, boolean failIfNotFound) { + if (nameOrId == null) { + if (failIfNotFound) throw new FcliSimpleException("No attribute found for name or id: null"); + return null; + } + FoDAttributeDefinitionDescriptor result = null; + try { + int id = Integer.parseInt(nameOrId); + result = getAllDefinitions().stream() + .filter(d -> d.getId() != null && d.getId() == id) + .findFirst().orElse(null); + } catch (NumberFormatException nfe) { + String trimmed = nameOrId.trim(); + result = getAllDefinitions().stream() + .filter(d -> d.getName() != null && d.getName().trim().equalsIgnoreCase(trimmed)) + .findFirst().orElse(null); + } + if (result == null && failIfNotFound) { + throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); + } + return result; + } + + public Map getRequiredDefaultValues(FoDEnums.AttributeTypes attrType) { + Map reqAttrs = new HashMap<>(); + for (var def : getAllDefinitions()) { + if (Boolean.TRUE.equals(def.getIsRequired()) + && (attrType.getValue() == 0 || def.getAttributeTypeId() == attrType.getValue())) { + var defaultValue = getDefaultValue(def); + if (defaultValue != null) { + reqAttrs.put(def.getName(), defaultValue); + } + } + } + return reqAttrs; + } + + public JsonNode buildAttributesNode(FoDEnums.AttributeTypes attrType, Map attributesMap, + boolean autoReqdAttributes) { + var effectiveMap = buildEffectiveAttributeUpdates(attrType, null, attributesMap, autoReqdAttributes); + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + for (Map.Entry attr : effectiveMap.entrySet()) { + var def = getDefinition(attr.getKey(), true); + if (attrType.getValue() == 0 || def.getAttributeTypeId() == attrType.getValue()) { + ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); + attrObj.put("id", def.getId()); + attrObj.put("value", attr.getValue()); + attrArray.add(attrObj); + } else { + LOG.debug("Skipping attribute '{}' as it is not a {} attribute", def.getName(), attrType); + } + } + return attrArray; + } + + public JsonNode buildAttributesNodeForUpdate(FoDEnums.AttributeTypes attrType, + ArrayList current, Map updates, boolean autoReqd) { + var effectiveUpdates = buildEffectiveAttributeUpdates(attrType, current, updates, autoReqd); + return effectiveUpdates.isEmpty() + ? attributeValuesToNode(current) + : mergeAttributesNode(current, effectiveUpdates); + } + + public JsonNode mergeAttributesNode(ArrayList current, Map updates) { + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + if (updates == null || updates.isEmpty()) return attrArray; + + Map updatesWithId = new HashMap<>(); + for (Map.Entry attr : updates.entrySet()) { + var def = getDefinition(attr.getKey(), true); + updatesWithId.put(def.getId(), attr.getValue()); + } + + Set processedIds = new HashSet<>(); + if (current != null) { + for (FoDAttributeValueDescriptor attr : current) { + int id = attr.getId(); + ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); + attrObj.put("id", id); + attrObj.put("value", updatesWithId.getOrDefault(id, attr.getValue())); + attrArray.add(attrObj); + processedIds.add(id); + } + } + + for (Map.Entry entry : updatesWithId.entrySet()) { + if (!processedIds.contains(entry.getKey())) { + ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); + attrObj.put("id", entry.getKey()); + attrObj.put("value", entry.getValue()); + attrArray.add(attrObj); + } + } + return attrArray; + } + + public static JsonNode attributeValuesToNode(ArrayList values) { + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + if (values == null || values.isEmpty()) return attrArray; + for (FoDAttributeValueDescriptor attr : values) { + ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); + attrObj.put("id", attr.getId()); + attrObj.put("value", attr.getValue()); + attrArray.add(attrObj); + } + return attrArray; + } + + public FoDAttributeDefinitionDescriptor createDefinition(FoDAttributeCreateRequest request) { + var response = unirest.post(FoDUrls.ATTRIBUTES) + .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") + .body(request) + .asObject(JsonNode.class) + .getBody(); + if (response.has("success") && response.get("success").asBoolean()) { + if (!response.has("attributeId")) { + throw new FcliSimpleException("Response missing attributeId: " + response.toString()); + } + return fetchFromApi(response.get("attributeId").asText(), true); + } else { + throw new FcliSimpleException("Failed to create attribute: " + response.toString()); + } + } + + public FoDAttributeDefinitionDescriptor updateDefinition(String attributeId, FoDAttributeUpdateRequest request) { + var response = unirest.put(FoDUrls.ATTRIBUTE) + .routeParam("attributeId", attributeId) + .body(request) + .asObject(JsonNode.class) + .getBody(); + if (response.has("success") && response.get("success").asBoolean()) { + return fetchFromApi(attributeId, true); + } else { + throw new FcliSimpleException("Failed to update attribute: " + response.toString()); + } + } + + private FoDAttributeDefinitionDescriptor fetchFromApi(String nameOrId, boolean fail) { + var request = unirest.get(FoDUrls.ATTRIBUTES); + JsonNode result; + try { + int id = Integer.parseInt(nameOrId); + result = FoDDataHelper.findUnique(request, String.format("id:%d", id)); + } catch (NumberFormatException nfe) { + result = FoDDataHelper.findUnique(request, String.format("name:%s", nameOrId)); + } + if (fail && result == null) { + throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); + } + return result == null ? null : JsonHelper.treeToValue(result, FoDAttributeDefinitionDescriptor.class); + } + + private Map buildEffectiveAttributeUpdates(FoDEnums.AttributeTypes attrType, + ArrayList currentAttributes, Map userSuppliedUpdates, + boolean autoReqdAttributes) { + var effective = new LinkedHashMap(); + if (autoReqdAttributes) { + Set covered = new HashSet<>(); + if (currentAttributes != null) { + currentAttributes.stream() + .filter(a -> StringUtils.isNotBlank(a.getValue())) + .map(FoDAttributeValueDescriptor::getName) + .forEach(covered::add); + } + if (userSuppliedUpdates != null) { + covered.addAll(userSuppliedUpdates.keySet()); + } + getRequiredDefaultValues(attrType) + .forEach((k, v) -> { if (!covered.contains(k)) effective.put(k, v); }); + } + if (userSuppliedUpdates != null) { + effective.putAll(userSuppliedUpdates); + } + return effective; + } + + private String getDefaultValue(FoDAttributeDefinitionDescriptor def) { + if (StringUtils.isNotBlank(def.getDefaultValue())) { + return def.getDefaultValue(); + } + return switch (def.getAttributeDataType()) { + case "Text" -> "autofilled by fcli"; + case "Boolean" -> String.valueOf(false); + case "User", "Picklist" -> def.getPicklistValues() != null && !def.getPicklistValues().isEmpty() + ? def.getPicklistValues().get(0).getName() : null; + default -> null; + }; + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java deleted file mode 100644 index 7b9c3cbba9a..00000000000 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.fod.attribute.helper; - -import java.util.*; - -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.rest.unirest.HttpHeader; -import com.fortify.cli.fod._common.rest.FoDUrls; -import com.fortify.cli.fod._common.rest.helper.FoDDataHelper; -import com.fortify.cli.fod._common.util.FoDEnums; - -import kong.unirest.GetRequest; -import kong.unirest.UnirestInstance; -import lombok.Getter; -import lombok.SneakyThrows; - -public class FoDAttributeHelper { - private static final Logger LOG = LoggerFactory.getLogger(FoDAttributeHelper.class); - @Getter private static ObjectMapper objectMapper = new ObjectMapper(); - - public static final FoDAttributeDescriptor getAttributeDescriptor(UnirestInstance unirestInstance, String attrNameOrId, boolean failIfNotFound) { - GetRequest request = unirestInstance.get(FoDUrls.ATTRIBUTES); - JsonNode result = null; - try { - int attrId = Integer.parseInt(attrNameOrId); - result = FoDDataHelper.findUnique(request, String.format("id:%d", attrId)); - } catch (NumberFormatException nfe) { - result = FoDDataHelper.findUnique(request, String.format("name:%s", attrNameOrId)); - } - if ( failIfNotFound && result==null ) { - throw new FcliSimpleException("No attribute found for name or id: " + attrNameOrId); - } - return result==null ? null : JsonHelper.treeToValue(result, FoDAttributeDescriptor.class); - } - - @SneakyThrows - public static final Map getRequiredAttributesDefaultValues(UnirestInstance unirestInstance, - FoDEnums.AttributeTypes attrType) { - Map reqAttrs = new HashMap<>(); - GetRequest request = unirestInstance.get(FoDUrls.ATTRIBUTES) - .queryString("filters", "isRequired:true"); - JsonNode items = request.asObject(ObjectNode.class).getBody().get("items"); - List lookupList = objectMapper.readValue(objectMapper.writeValueAsString(items), - new TypeReference>() { - }); - Iterator lookupIterator = lookupList.iterator(); - while (lookupIterator.hasNext()) { - FoDAttributeDescriptor currentLookup = lookupIterator.next(); - // currentLookup.getAttributeTypeId() == 1 if "Application", 4 if "Release" - filter above does not support querying on this yet! - if (currentLookup.getIsRequired() && (attrType.getValue() == 0 || currentLookup.getAttributeTypeId() == attrType.getValue())) { - var defaultValue = getDefaultValue(currentLookup); - if (defaultValue != null) { - reqAttrs.put(currentLookup.getName(), defaultValue); - } - } - } - return reqAttrs; - } - - public static JsonNode mergeAttributesNode(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, - ArrayList current, - Map updates) { - ArrayNode attrArray = objectMapper.createArrayNode(); - if (updates == null || updates.isEmpty()) return attrArray; - - // Map attribute id to value from updates - Map updatesWithId = new HashMap<>(); - for (Map.Entry attr : updates.entrySet()) { - FoDAttributeDescriptor desc = getAttributeDescriptor(unirest, attr.getKey(), true); - updatesWithId.put(desc.getId(), attr.getValue()); - } - - // Track which ids have been processed - Set processedIds = new HashSet<>(); - - // Add current attributes, updating values if present in updates - if (current != null) { - for (FoDAttributeDescriptor attr : current) { - int id = attr.getId(); - ObjectNode attrObj = objectMapper.createObjectNode(); - attrObj.put("id", id); - attrObj.put("value", updatesWithId.getOrDefault(id, attr.getValue())); - attrArray.add(attrObj); - processedIds.add(id); - } - } - - // Add new attributes from updates not already in current - for (Map.Entry entry : updatesWithId.entrySet()) { - if (!processedIds.contains(entry.getKey())) { - ObjectNode attrObj = objectMapper.createObjectNode(); - attrObj.put("id", entry.getKey()); - attrObj.put("value", entry.getValue()); - attrArray.add(attrObj); - } - } - - return attrArray; - } - - public static JsonNode getAttributesNode(FoDEnums.AttributeTypes attrType, ArrayList attributes) { - ArrayNode attrArray = objectMapper.createArrayNode(); - if (attributes == null || attributes.isEmpty()) return attrArray; - for (FoDAttributeDescriptor attr : attributes) { - ObjectNode attrObj = objectMapper.createObjectNode(); - attrObj.put("id", attr.getId()); - attrObj.put("value", attr.getValue()); - attrArray.add(attrObj); - } - return attrArray; - } - - /** - * For create commands: amends user-provided attribute values with server-side defaults - * for any required attributes not already specified by the user. - * Resolves attribute names to IDs and filters by attrType. - */ - public static JsonNode getAttributesNode(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, - Map attributesMap, boolean autoReqdAttributes) { - var effectiveMap = buildEffectiveAttributeUpdates(unirest, attrType, null, attributesMap, autoReqdAttributes); - ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - for (Map.Entry attr : effectiveMap.entrySet()) { - ObjectNode attrObj = getObjectMapper().createObjectNode(); - FoDAttributeDescriptor attributeDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attr.getKey(), true); - // filter out any attributes that aren't valid for the entity we are working on, e.g. Application or Release - if (attrType.getValue() == 0 || attributeDescriptor.getAttributeTypeId() == attrType.getValue()) { - attrObj.put("id", attributeDescriptor.getId()); - attrObj.put("value", attr.getValue()); - attrArray.add(attrObj); - } else { - LOG.debug("Skipping attribute '"+attributeDescriptor.getName()+"' as it is not a "+attrType.toString()+" attribute"); - } - } - return attrArray; - } - - /** - * For update commands: merges user-supplied attribute values with the entity's existing - * attribute values, then amends with server-side defaults for any required attributes - * not already covered by either the current values or user-supplied updates. - */ - public static JsonNode getAttributesNodeForUpdate(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, - ArrayList currentAttributes, Map userSuppliedUpdates, - boolean autoReqdAttributes) { - var effectiveUpdates = buildEffectiveAttributeUpdates( - unirest, attrType, currentAttributes, userSuppliedUpdates, autoReqdAttributes); - return effectiveUpdates.isEmpty() - ? getAttributesNode(attrType, currentAttributes) - : mergeAttributesNode(unirest, attrType, currentAttributes, effectiveUpdates); - } - - /** - * Computes the effective attribute updates to apply. For required attributes not already - * covered by current entity values or user-supplied updates, server-side defaults are added. - * User-supplied updates always take highest priority. - * Pass {@code currentAttributes=null} for create scenarios. - */ - private static Map buildEffectiveAttributeUpdates(UnirestInstance unirest, - FoDEnums.AttributeTypes attrType, ArrayList currentAttributes, - Map userSuppliedUpdates, boolean autoReqdAttributes) { - var effective = new LinkedHashMap(); - if (autoReqdAttributes) { - Set covered = new HashSet<>(); - if (currentAttributes != null) { - currentAttributes.stream() - .filter(a -> StringUtils.isNotBlank(a.getValue())) - .map(FoDAttributeDescriptor::getName) - .forEach(covered::add); - } - if (userSuppliedUpdates != null) { - covered.addAll(userSuppliedUpdates.keySet()); - } - getRequiredAttributesDefaultValues(unirest, attrType) - .forEach((k, v) -> { if (!covered.contains(k)) effective.put(k, v); }); - } - if (userSuppliedUpdates != null) { - effective.putAll(userSuppliedUpdates); - } - return effective; - } - - public static FoDAttributeDescriptor createAttribute(UnirestInstance unirest, FoDAttributeCreateRequest request) { - var response = unirest.post(FoDUrls.ATTRIBUTES) - // Use headerReplace to replace rather than add the Content-Type header (avoid duplicates with defaults) - .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") - .body(request) - .asObject(JsonNode.class) - .getBody(); - if (response.has("success") && response.get("success").asBoolean()) { - if (!response.has("attributeId")) { - throw new FcliSimpleException("Response missing attributeId: " + response.toString()); - } - var attributeId = response.get("attributeId").asText(); - return getAttributeDescriptor(unirest, attributeId, true); - } else { - throw new FcliSimpleException("Failed to create attribute: " + response.toString()); - } - - } - - public static FoDAttributeDescriptor updateAttribute(UnirestInstance unirest, String attributeId, FoDAttributeUpdateRequest request) { - var response = unirest.put(FoDUrls.ATTRIBUTE) - .routeParam("attributeId", attributeId) - .body(request) - .asObject(JsonNode.class) - .getBody(); - if (response.has("success") && response.get("success").asBoolean()) { - return getAttributeDescriptor(unirest, attributeId, true); - } else { - throw new FcliSimpleException("Failed to update attribute: " + response.toString()); - } - - } - - private static String getDefaultValue(FoDAttributeDescriptor attribute) { - if (StringUtils.isNotBlank(attribute.getDefaultValue())) { - return attribute.getDefaultValue(); - } - return switch (attribute.getAttributeDataType()) { - case "Text" -> "autofilled by fcli"; - case "Boolean" -> String.valueOf(false); - case "User", "Picklist" -> attribute.getPicklistValues() != null && !attribute.getPicklistValues().isEmpty() - ? attribute.getPicklistValues().get(0).getName() : null; - default -> null; - }; - } -} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java new file mode 100644 index 00000000000..b842889b651 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeValueDescriptor.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.attribute.helper; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.json.JsonNodeHolder; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data @EqualsAndHashCode(callSuper = true) +@Reflectable @NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class FoDAttributeValueDescriptor extends JsonNodeHolder { + private Integer id; + private String name; + private String value; +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java index ea9f25ddeaa..7aec6a3c085 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java @@ -19,7 +19,7 @@ import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.mcp.MCPInclude; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -31,6 +31,7 @@ import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; import com.fortify.cli.fod.issue.helper.FoDBulkIssueUpdateRequest; import com.fortify.cli.fod.issue.helper.FoDBulkIssueUpdateResponse; +import com.fortify.cli.fod.issue.helper.FoDIssueAttributeHelper; import com.fortify.cli.fod.issue.helper.FoDIssueHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; @@ -55,7 +56,6 @@ public class FoDIssueUpdateCommand extends AbstractFoDJsonNodeOutputCommand impl @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; @Mixin private FoDAttributeUpdateOptions.OptionalAttrOption issueAttrsUpdate; - private final ObjectMapper objectMapper = new ObjectMapper(); @Option(names = {"--user"}, required = true) protected String user; @@ -82,8 +82,9 @@ public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); Map attributeUpdates = issueAttrsUpdate.getAttributes(); - JsonNode jsonAttrs = FoDIssueHelper.buildIssueAttributesNode(unirest, attributeUpdates); - ResolvedStatuses resolvedStatuses = resolveStatuses(unirest); + var issueAttrHelper = new FoDIssueAttributeHelper(unirest); + JsonNode jsonAttrs = issueAttrHelper.buildAttributesNode(attributeUpdates); + ResolvedStatuses resolvedStatuses = resolveStatuses(issueAttrHelper); if (vulnSelection.includeAllVulnerabilities) { FoDBulkIssueUpdateRequest issueUpdateRequest = buildIssueUpdateRequest(unirest, resolvedStatuses.developerStatusValue(), resolvedStatuses.auditorStatusValue(), jsonAttrs, null, true); @@ -124,7 +125,7 @@ private JsonNode createNoOpResponse(int totalCount, int skippedCount, int issueU lastSkippedCount = skippedCount; lastErrorCount = 0; lastUpdateCount = issueUpdateCount; - return objectMapper.createObjectNode() + return JsonHelper.getObjectMapper().createObjectNode() .put("totalCount", totalCount) .put("skippedCount", skippedCount) .put("errorCount", 0) @@ -133,16 +134,16 @@ private JsonNode createNoOpResponse(int totalCount, int skippedCount, int issueU private record ResolvedStatuses(String developerStatusValue, String auditorStatusValue) {} - private ResolvedStatuses resolveStatuses(UnirestInstance unirest) { + private ResolvedStatuses resolveStatuses(FoDIssueAttributeHelper issueAttrHelper) { String auditorStatusValue = null; if ( auditorStatus != null && !auditorStatus.isBlank() ) { - auditorStatusValue = FoDIssueHelper.resolveStatusValue(unirest, auditorStatus, new String[]{ + auditorStatusValue = issueAttrHelper.resolveStatusValue(auditorStatus, new String[]{ "Auditor Status (Non suppressed)", "Auditor Status (Suppressed)" }, "auditor-status", AuditorStatusType.values()); } String developerStatusValue = null; if ( developerStatus != null && !developerStatus.isBlank() ) { - developerStatusValue = FoDIssueHelper.resolveStatusValue(unirest, developerStatus, new String[]{ + developerStatusValue = issueAttrHelper.resolveStatusValue(developerStatus, new String[]{ "Developer Status (Open)", "Developer Status (Closed)" }, "developer-status", DeveloperStatusType.values()); } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java new file mode 100644 index 00000000000..6f5f76defaa --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueAttributeHelper.java @@ -0,0 +1,129 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.issue.helper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.fod._common.util.FoDEnums; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; + +import kong.unirest.UnirestInstance; + +public class FoDIssueAttributeHelper { + private static final Logger LOG = LoggerFactory.getLogger(FoDIssueAttributeHelper.class); + private final FoDAttributeDefinitionHelper definitionHelper; + + public FoDIssueAttributeHelper(UnirestInstance unirest) { + this(new FoDAttributeDefinitionHelper(unirest)); + } + + public FoDIssueAttributeHelper(FoDAttributeDefinitionHelper definitionHelper) { + this.definitionHelper = definitionHelper; + } + + public ArrayNode buildAttributesNode(Map attributeUpdates) { + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + if (attributeUpdates == null || attributeUpdates.isEmpty()) return attrArray; + for (Map.Entry e : attributeUpdates.entrySet()) { + var def = definitionHelper.getDefinition(e.getKey(), true); + if (def != null && Objects.equals(def.getAttributeTypeId(), FoDEnums.AttributeTypes.Issue.getValue())) { + var obj = JsonHelper.getObjectMapper().createObjectNode(); + obj.put("id", def.getId()); + obj.put("value", e.getValue()); + attrArray.add(obj); + } else if (def != null) { + LOG.debug("Skipping attribute '{}' as it is not an Issue attribute", def.getName()); + } + } + return attrArray; + } + + public String resolveStatusValue(String value, String[] attrNames, String optionName) { + FoDEnums.DeveloperStatusType[] devEnum = FoDEnums.DeveloperStatusType.values(); + FoDEnums.AuditorStatusType[] audEnum = FoDEnums.AuditorStatusType.values(); + if (optionName != null && optionName.toLowerCase().contains("developer")) { + return resolveStatusValue(value, attrNames, optionName, devEnum); + } else if (optionName != null && optionName.toLowerCase().contains("auditor")) { + return resolveStatusValue(value, attrNames, optionName, audEnum); + } + return resolveStatusValue(value, attrNames, optionName, (FoDEnums.DeveloperStatusType[]) null); + } + + public & FoDEnums.IFoDEnumValueSupplier> String resolveStatusValue( + String value, String[] attrNames, String optionName, T[] enumValues) { + if (value == null || value.isBlank()) { return null; } + String originalProvided = value; + String candidate = value.trim(); + try { + if (enumValues != null) { + var resolved = FoDEnums.IFoDEnumValueSupplier.resolveEnumValue(candidate, enumValues); + if (resolved.isPresent()) { candidate = resolved.get(); } + } + } catch (Exception e) { + LOG.debug("Error resolving enum-style status value for {}: {}", optionName, e.getMessage()); + } + + String attrResolved = tryResolveAgainstAttributes(attrNames, candidate); + if (attrResolved != null) return attrResolved; + + var allowed = collectAllowedAttributeValues(attrNames); + throw new FcliSimpleException(String.format("Invalid %s '%s'. Allowed values: %s", + optionName, originalProvided, String.join(", ", allowed))); + } + + private String tryResolveAgainstAttributes(String[] attributeNames, String candidate) { + for (String attrName : attributeNames) { + var desc = definitionHelper.getDefinition(attrName, false); + if (desc == null) continue; + var picklist = desc.getPicklistValues(); + if (picklist == null || picklist.isEmpty()) continue; + for (var pv : picklist) { + if (pv.getName() != null && pv.getName().equalsIgnoreCase(candidate)) { + return pv.getName(); + } + } + try { + int providedId = Integer.parseInt(candidate); + for (var pv : picklist) { + if (Objects.equals(pv.getId(), providedId)) { + return pv.getName(); + } + } + } catch (NumberFormatException ignored) {} + } + return null; + } + + private List collectAllowedAttributeValues(String[] attributeNames) { + var allowed = new ArrayList(); + for (String attrName : attributeNames) { + var desc = definitionHelper.getDefinition(attrName, false); + if (desc == null) continue; + var picklist = desc.getPicklistValues(); + if (picklist == null) continue; + for (var pv : picklist) { + allowed.add(pv.getName()); + } + } + return allowed; + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java index 24a29cd5de6..cef7e049915 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java @@ -19,18 +19,14 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.json.transform.fields.RenameFieldsTransformer; @@ -38,97 +34,14 @@ import com.fortify.cli.fod._common.rest.helper.FoDInputTransformer; import com.fortify.cli.fod._common.rest.helper.FoDPagingHelper; import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; -import com.fortify.cli.fod._common.util.FoDEnums; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import kong.unirest.HttpResponse; import kong.unirest.UnirestInstance; import lombok.Builder; import lombok.Data; -import lombok.Getter; public class FoDIssueHelper { private static final Logger LOG = LoggerFactory.getLogger(FoDIssueHelper.class); - @Getter private static ObjectMapper objectMapper = new ObjectMapper(); - - // Local cache for attribute descriptors used during bulk issue updates. Populated by loadAllAttributes(). - private static final ConcurrentHashMap ATTR_CACHE_BY_NAME = new ConcurrentHashMap<>(); - private static final ConcurrentHashMap ATTR_CACHE_BY_ID = new ConcurrentHashMap<>(); - private static volatile boolean attributesPrefetched = false; - - /** - * Prefetch all attributes from FoD and populate the local cache. Safe to call multiple times; will perform - * the fetch only once per JVM unless clearAttributesCache() is called. - */ - public static synchronized void loadAllAttributes(UnirestInstance unirest) { - if ( attributesPrefetched ) return; - // Use local temporary maps to build the cache so a failed fetch/parse doesn't leave partial data - var tmpById = new HashMap(); - var tmpByName = new HashMap(); - try { - var request = unirest.get(FoDUrls.ATTRIBUTES); - var body = request.asObject(ObjectNode.class).getBody(); - var items = body.get("items"); - if ( items!=null && items.isArray() ) { - for (var item : items) { - FoDAttributeDescriptor desc = JsonHelper.treeToValue(item, FoDAttributeDescriptor.class); - if ( desc!=null ) { - tmpById.putIfAbsent(desc.getId(), desc); - if ( desc.getName()!=null ) { - tmpByName.putIfAbsent(desc.getName(), desc); - tmpByName.putIfAbsent(desc.getName().trim(), desc); - } - } - } - } - // Merge into the concurrent caches; preserve any existing descriptors using putIfAbsent - for ( var e : tmpById.entrySet() ) { - ATTR_CACHE_BY_ID.putIfAbsent(e.getKey(), e.getValue()); - } - for ( var e : tmpByName.entrySet() ) { - ATTR_CACHE_BY_NAME.putIfAbsent(e.getKey(), e.getValue()); - } - attributesPrefetched = true; // set only after successful population - } catch (kong.unirest.UnirestException e) { - throw new FcliTechnicalException("Error loading attribute descriptors", e); - } catch (Exception e) { - throw new FcliTechnicalException("Error processing attribute descriptors", e); - } - } - - public static void clearAttributesCache() { - ATTR_CACHE_BY_ID.clear(); - ATTR_CACHE_BY_NAME.clear(); - attributesPrefetched = false; - } - - /** - * Resolve an attribute descriptor from the local cache. If not prefetched yet, will call loadAllAttributes. - */ - public static FoDAttributeDescriptor getAttributeDescriptorFromCache(UnirestInstance unirest, String nameOrId, boolean failIfNotFound) { - if ( !attributesPrefetched ) { - loadAllAttributes(unirest); - } - if ( nameOrId==null ) { - if ( failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: null"); - return null; - } - try { - int id = Integer.parseInt(nameOrId); - var desc = ATTR_CACHE_BY_ID.get(id); - if ( desc==null && failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); - return desc; - } catch (NumberFormatException nfe) { - var desc = ATTR_CACHE_BY_NAME.get(nameOrId); - if ( desc==null ) { - // try trimmed - desc = ATTR_CACHE_BY_NAME.get(nameOrId.trim()); - } - if ( desc==null && failIfNotFound ) throw new FcliSimpleException("No attribute found for name or id: " + nameOrId); - return desc; - } - } public static final JsonNode transformRecord(JsonNode record) { return new RenameFieldsTransformer(new String[]{}).transform(record); @@ -212,7 +125,7 @@ public static final ObjectNode transformRecord(ObjectNode record, IssueAggregati } public static final FoDBulkIssueUpdateResponse updateIssues(UnirestInstance unirest, String releaseId, FoDBulkIssueUpdateRequest issueUpdateRequest) { - ObjectNode body = objectMapper.valueToTree(issueUpdateRequest); + ObjectNode body = JsonHelper.getObjectMapper().valueToTree(issueUpdateRequest); var result = unirest.post(FoDUrls.VULNERABILITIES + "/bulk-edit") .routeParam("relId", releaseId) .body(body).asObject(JsonNode.class).getBody(); @@ -397,80 +310,6 @@ public static List getAllVulnNumericIdsForRelease(UnirestInstance unires return result; } - /** - * Resolve a status value (developer/auditor) against one or more FoD attribute picklists. - * Returns the canonical picklist name when found, or throws a FcliSimpleException listing allowed values. - */ - public static String resolveStatusValue(UnirestInstance unirest, String providedValue, String[] attributeNames, String optionName) { - // Maintain compatibility by delegating to the generic overload; try to infer enum when optionName indicates developer/auditor - FoDEnums.DeveloperStatusType[] devEnum = FoDEnums.DeveloperStatusType.values(); - FoDEnums.AuditorStatusType[] audEnum = FoDEnums.AuditorStatusType.values(); - if ( optionName!=null && optionName.toLowerCase().contains("developer") ) { - return resolveStatusValue(unirest, providedValue, attributeNames, optionName, devEnum); - } else if ( optionName!=null && optionName.toLowerCase().contains("auditor") ) { - return resolveStatusValue(unirest, providedValue, attributeNames, optionName, audEnum); - } - return resolveStatusValue(unirest, providedValue, attributeNames, optionName, (FoDEnums.DeveloperStatusType[])null); - } - - public static & FoDEnums.IFoDEnumValueSupplier> String resolveStatusValue(UnirestInstance unirest, String providedValue, String[] attributeNames, String optionName, T[] enumValues) { - if ( providedValue==null || providedValue.isBlank() ) { return null; } - String originalProvided = providedValue; - String candidate = providedValue.trim(); - try { - if ( enumValues!=null ) { - var resolved = FoDEnums.IFoDEnumValueSupplier.resolveEnumValue(candidate, enumValues); - if ( resolved.isPresent() ) { candidate = resolved.get(); } - } - } catch (Exception e) { - LOG.debug("Error resolving enum-style status value for {}: {}", optionName, e.getMessage()); - } - - String attrResolved = tryResolveAgainstAttributes(unirest, attributeNames, candidate); - if ( attrResolved!=null ) return attrResolved; - - var allowed = collectAllowedAttributeValues(unirest, attributeNames); - throw new FcliSimpleException(String.format("Invalid %s '%s'. Allowed values: %s", optionName, originalProvided, String.join(", ", allowed))); - } - - private static String tryResolveAgainstAttributes(UnirestInstance unirest, String[] attributeNames, String candidate) { - for (String attrName: attributeNames) { - var desc = FoDAttributeHelper.getAttributeDescriptor(unirest, attrName, false); - if ( desc==null ) continue; - var picklist = desc.getPicklistValues(); - if ( picklist==null || picklist.isEmpty() ) continue; - for (var pv : picklist) { - if ( pv.getName()!=null && pv.getName().equalsIgnoreCase(candidate) ) { - return pv.getName(); - } - } - // if provided value looks like an id, try matching by id - try { - int providedId = Integer.parseInt(candidate); - for (var pv : picklist) { - if ( Objects.equals(pv.getId(), providedId) ) { - return pv.getName(); - } - } - } catch (NumberFormatException ignored) {} - } - return null; - } - - private static List collectAllowedAttributeValues(UnirestInstance unirest, String[] attributeNames) { - var allowed = new ArrayList(); - for (String attrName: attributeNames) { - var desc = FoDAttributeHelper.getAttributeDescriptor(unirest, attrName, false); - if ( desc==null ) continue; - var picklist = desc.getPicklistValues(); - if ( picklist==null ) continue; - for (var pv: picklist) { - allowed.add(pv.getName()); - } - } - return allowed; - } - /** * Result carrier for vuln filtering: kept (normalized ids to update), skipped (original values skipped), totalCount */ @@ -522,30 +361,4 @@ private static String normalizeVulnId(String id) { } return v.isEmpty() ? null : v; } - - /** - * Build an ArrayNode of attribute objects (id/value) for Issue attribute updates using the localized - * attribute cache. Will prefetch attributes if necessary. - */ - public static ArrayNode buildIssueAttributesNode(UnirestInstance unirest, Map attributeUpdates) { - ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - if ( attributeUpdates==null || attributeUpdates.isEmpty() ) return attrArray; - // Ensure local cache populated - loadAllAttributes(unirest); - for ( Map.Entry e : attributeUpdates.entrySet() ) { - String attrName = e.getKey(); - String value = e.getValue(); - FoDAttributeDescriptor attributeDescriptor = getAttributeDescriptorFromCache(unirest, attrName, true); - // Only include attributes that are Issue-scoped (AttributeTypes.Issue) - if ( attributeDescriptor!=null && attributeDescriptor.getAttributeTypeId() == FoDEnums.AttributeTypes.Issue.getValue() ) { - var obj = JsonHelper.getObjectMapper().createObjectNode(); - obj.put("id", attributeDescriptor.getId()); - obj.put("value", value); - attrArray.add(obj); - } else { - LOG.debug("Skipping attribute '{}' as it is not an Issue attribute", attributeDescriptor.getName()); - } - } - return attrArray; - } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java index 6ce2013820e..1a6f78c5023 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java @@ -21,7 +21,7 @@ import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.microservice.cli.mixin.FoDMicroserviceByQualifiedNameResolverMixin; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; @@ -54,7 +54,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { FoDQualifiedMicroserviceNameDescriptor qualifiedMicroserviceNameDescriptor = qualifiedMicroserviceNameResolver.getQualifiedMicroserviceNameDescriptor(); FoDMicroserviceUpdateRequest msCreateRequest = FoDMicroserviceUpdateRequest.builder() .microserviceName(qualifiedMicroserviceNameDescriptor.getMicroserviceName()) - .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, + .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Microservice, msAttrs.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs())) .build(); return FoDMicroserviceHelper.createMicroservice(unirest, appDescriptor, msCreateRequest).asJsonNode(); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java index a37ea2d6edc..99aa47ab540 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java @@ -12,19 +12,16 @@ */ package com.fortify.cli.fod.microservice.cli.cmd; -import java.util.ArrayList; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; -import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import com.fortify.cli.fod.microservice.cli.mixin.FoDMicroserviceByQualifiedNameResolverMixin; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; @@ -39,7 +36,6 @@ @Command(name = OutputHelperMixins.Update.CMD_NAME) public class FoDMicroserviceUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.Update outputHelper; - private final ObjectMapper objectMapper = new ObjectMapper(); @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDMicroserviceByQualifiedNameResolverMixin.PositionalParameter microserviceResolver; @@ -52,20 +48,14 @@ public class FoDMicroserviceUpdateCommand extends AbstractFoDJsonNodeOutputComma @Override public JsonNode getJsonNode(UnirestInstance unirest) { FoDMicroserviceDescriptor msDescriptor = microserviceResolver.getMicroserviceDescriptor(unirest, true); - ArrayList msAttrsCurrent = msDescriptor.getAttributes(); + var attrHelper = new FoDAttributeDefinitionHelper(unirest); + java.util.ArrayList msAttrsCurrent = msDescriptor.getAttributes(); Map attributeUpdates = msAttrsUpdate.getAttributes(); - JsonNode jsonAttrs = objectMapper.createArrayNode(); - if (attributeUpdates != null && !attributeUpdates.isEmpty()) { - jsonAttrs = FoDAttributeHelper.mergeAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, - msAttrsCurrent, attributeUpdates); - } else { - jsonAttrs = FoDAttributeHelper.getAttributesNode(FoDEnums.AttributeTypes.Microservice, msAttrsCurrent); - } - FoDMicroserviceDescriptor appMicroserviceDescriptor = microserviceResolver.getMicroserviceDescriptor(unirest, true); + JsonNode jsonAttrs = attrHelper.buildAttributesNodeForUpdate(null, msAttrsCurrent, attributeUpdates, false); FoDMicroserviceUpdateRequest msUpdateRequest = FoDMicroserviceUpdateRequest.builder() .microserviceName(microserviceName) .attributes(jsonAttrs).build(); - return FoDMicroserviceHelper.updateMicroservice(unirest, appMicroserviceDescriptor, msUpdateRequest).asJsonNode(); + return FoDMicroserviceHelper.updateMicroservice(unirest, msDescriptor, msUpdateRequest).asJsonNode(); } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java index d1b2d7c6933..8e4c3cc1487 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceDescriptor.java @@ -18,7 +18,7 @@ import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -31,11 +31,11 @@ public class FoDMicroserviceDescriptor extends JsonNodeHolder { private String applicationName; private String microserviceId; private String microserviceName; - private ArrayList attributes; + private ArrayList attributes; public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeDescriptor attr : attributes) { + for (FoDAttributeValueDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java index 23d543530fc..94962745dc3 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/helper/FoDMicroserviceHelper.java @@ -26,7 +26,7 @@ import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import kong.unirest.UnirestInstance; @@ -99,7 +99,7 @@ public static final FoDMicroserviceDescriptor createMicroservice(UnirestInstance boolean autoRequiredAttrs) { var request = FoDMicroserviceUpdateRequest.builder() .microserviceName(microserviceName) - .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, + .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Microservice, msAttrs.getAttributes(), autoRequiredAttrs)) .build(); return createMicroservice(unirest, appDescriptor, request); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java index 50080122c7b..1ddcba8553d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseCreateCommand.java @@ -40,7 +40,7 @@ import com.fortify.cli.fod.app.helper.FoDAppDescriptor; import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceDescriptor; import com.fortify.cli.fod.microservice.helper.FoDMicroserviceHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; @@ -150,7 +150,7 @@ private final ObjectNode createRelease(UnirestInstance unirest, FoDAppDescriptor .releaseName(simpleReleaseName) .releaseDescription(description) .sdlcStatusType(sdlcStatus.getSdlcStatusType().name()) - .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Release, + .attributes(new FoDAttributeDefinitionHelper(unirest).buildAttributesNode(FoDEnums.AttributeTypes.Release, relAttrs.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs())); requestBuilder = addMicroservice(microserviceDescriptor, requestBuilder); requestBuilder = addCopyFrom(unirest, appDescriptor, requestBuilder); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java index 8649b783452..41d0c73f61f 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java @@ -25,7 +25,7 @@ import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.app.cli.mixin.FoDSdlcStatusTypeOptions; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDefinitionHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; import com.fortify.cli.fod.release.helper.FoDReleaseHelper; @@ -63,7 +63,7 @@ public class FoDReleaseUpdateCommand extends AbstractFoDJsonNodeOutputCommand im public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); FoDSdlcStatusTypeOptions.FoDSdlcStatusType sdlcStatusTypeNew = sdlcStatus.getSdlcStatusType(); - JsonNode jsonAttrs = FoDAttributeHelper.getAttributesNodeForUpdate(unirest, FoDEnums.AttributeTypes.Release, + JsonNode jsonAttrs = new FoDAttributeDefinitionHelper(unirest).buildAttributesNodeForUpdate(FoDEnums.AttributeTypes.Release, releaseDescriptor.getAttributes(), appAttrsUpdate.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs()); FoDReleaseUpdateRequest appRelUpdateRequest = FoDReleaseUpdateRequest.builder() diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java index fdea71e369d..cdb54a3e9d2 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/helper/FoDReleaseDescriptor.java @@ -22,7 +22,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.json.JsonNodeHolder; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeValueDescriptor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -58,7 +58,7 @@ public class FoDReleaseDescriptor extends JsonNodeHolder { private LocalDateTime staticScanDate; private LocalDateTime dynamicScanDate; private LocalDateTime mobileScanDate; - private ArrayList attributes; + private ArrayList attributes; @JsonIgnore public String getQualifiedName() { return StringUtils.isBlank(microserviceName) @@ -74,7 +74,7 @@ public String getQualifierPrefix(String delimiter) { public Map attributesAsMap() { Map attrMap = new HashMap<>(); - for (FoDAttributeDescriptor attr : attributes) { + for (FoDAttributeValueDescriptor attr : attributes) { attrMap.put(attr.getId(), attr.getValue()); } return attrMap; From 31c792f99210adb3ef01b0147469eaa8ab45af98 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 14 May 2026 13:10:42 +0200 Subject: [PATCH 22/55] chore: Update Copilot instructions --- .github/copilot-instructions.md | 48 +++++++++++++----- .github/instructions/java.instructions.md | 49 ++++++++++++++++-- .github/instructions/style.instructions.md | 44 +++++++++++++--- .../instructions/utilities.instructions.md | 50 +++++++++++++++++-- 4 files changed, 164 insertions(+), 27 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8ebfcbf5eaf..3f3137672b3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,19 +29,41 @@ Fcli is a modular CLI tool for interacting with Fortify products (FoD, SSC, Scan - Short methods (~20 lines max); extract helpers or use Streams for clarity - No change-tracking comments (e.g., "New ...", "Updated ..."); only explanatory comments when code is complex -## Maintaining Instructions - -**If you detect discrepancies between these instructions and the actual implementation**, or discover patterns/features not documented here: - -1. **Notify the user** about the discrepancy or missing documentation -2. **Suggest specific updates** to the relevant instruction file(s) -3. **Verify against current code** before making changes based on outdated instructions - -This applies to: -- Main instructions (this file) -- Specific instruction files in `.github/instructions/` -- Examples that no longer match current patterns -- Missing documentation for new features or utilities +## Agent Behavior + +**Think and work as a senior/expert developer** on every task: +- Apply SOLID principles, proper separation of concerns, and established design patterns +- Treat code quality and security as non-negotiable, not optional extras +- Never guess or make assumptions about how existing code works — analyze first: + 1. Use `semantic_search`, `grep_search`, and `read_file` to trace through related code + 2. Find all callers of anything you change (`vscode_listCodeUsages`) + 3. Understand the design intent before writing a single line +- Identify and use the most appropriate existing abstraction instead of creating a new one +- If a request is ambiguous, surface the design trade-offs and ask; do not silently pick one + +## Self-Learning Instructions + +**Automatically update these instruction files** when you learn or implement something new that would help in future sessions. Do not just notify the user — make the edit as part of completing the task. + +Update the file that best scopes the knowledge: +- `copilot-instructions.md` — project-wide workflow facts and agent behavior rules +- `.github/instructions/java.instructions.md` — Java patterns, architecture, APIs +- `.github/instructions/style.instructions.md` — naming, formatting, AI assistant rules +- `.github/instructions/utilities.instructions.md` — utility class discoveries + +Rules for self-updating: +- Only add content that is **reusable and non-obvious** (things a smart developer wouldn't know without reading the source) +- Keep additions **concise** — bullet points and short examples, not prose explanations; no introductory paragraphs +- A method signature + one-liner purpose is enough; omit obvious behaviour +- Correct or remove instructions that turn out to be wrong +- Never duplicate existing content; prefer extending an existing section +- Verify the change compiles/is consistent before updating instructions + +**If you detect discrepancies** between these instructions and the actual implementation: +1. **Fix the instruction** to match reality (or fix the code if it's a bug) +2. **Verify against current code** before proceeding — never rely on stale instructions + +This applies to all instruction files in `.github/instructions/`. ## Context-Specific Instructions diff --git a/.github/instructions/java.instructions.md b/.github/instructions/java.instructions.md index af972eb0de8..85222b04d71 100644 --- a/.github/instructions/java.instructions.md +++ b/.github/instructions/java.instructions.md @@ -10,9 +10,9 @@ applyTo: 'fcli/**/*.java' **If you detect discrepancies** between these instructions and the actual implementation, or discover patterns/features not documented here: -1. **Notify the user** about the discrepancy or missing documentation -2. **Suggest specific updates** to this instruction file -3. **Verify against current code** before proceeding with changes based on potentially outdated instructions +1. **Automatically update this file** to reflect the correct pattern or add the missing information +2. **Verify against current code** before documenting — never guess at intent +3. Only add content that is **reusable and non-obvious**; keep additions concise ## Architecture Overview @@ -129,6 +129,49 @@ try { parse(json); } catch (JsonProcessingException e) { throw new FcliTechnical default -> throw new FcliBugException("Unexpected status: "+status); ``` +## Design Patterns in fcli + +Fcli uses several established patterns consistently. Before creating new abstractions, identify whether one of these already covers your need: + +### Template Method +Abstract base classes define the algorithm skeleton; subclasses fill in the steps. +- `AbstractRunnableCommand.call()` orchestrates option validation → execution → output +- `AbstractSessionLoginCommand` handles session lifecycle; subclasses provide credentials and connection logic +- **Rule:** Override the narrowest hook method, not the full template + +### Strategy (via Interfaces + Mixins) +Behavior is injected at the call-site rather than baked into the consumer. +- `IOutputConfigSupplier` lets commands declare default format/columns without knowing the writer +- `IRecordWriter` implementations (CSV, JSON, table, …) are selected at runtime by `RecordWriterFactory` +- `UnirestInstanceSupplierMixin` injects an HTTP client configured for the active session +- **Rule:** When a class needs pluggable behavior, define an interface and inject it via `@Mixin` or constructor; avoid `if/else` type-switches + +### Factory / Registry +Creation and type selection are centralized. +- `RecordWriterFactory` enum maps output format → writer implementation +- `OutputHelperMixins` inner classes pair a command name with its default output config +- **Rule:** Add new variants by extending the factory/enum, not by modifying consumer code (Open/Closed) + +### Composite (Command Tree) +Picocli's subcommand tree is a composite: container commands own leaf commands. +- **Rule:** Container commands contain only `@Command` metadata and subcommand registration; zero business logic + +### Mixin / Decorator (Picocli `@Mixin`) +Cross-cutting CLI concerns (output, session, pagination, …) are composed in, not inherited. +- `OutputHelperMixins.List` brings `--output`, `--query`, `--store` to any list command +- **Rule:** Prefer mixins over base-class inheritance for optional/composable features + +### Builder (Unirest fluent API) +HTTP requests are assembled step-by-step. +- Always use `headerReplace()` not `header()` / `accept()` / `contentType()` (see Unirest section) + +### Separation of Concerns checklist +Before committing, verify: +- Command class: only option fields, `@Mixin` injections, and a short `call()` that delegates +- Helper/service class: business logic; no Picocli annotations, no direct I/O +- Writer/formatter class: output shaping only; no HTTP calls, no business logic +- Session descriptor: data only (record or simple POJO); no network code + ## Common Utility Classes The `fcli-common` module provides utility classes in `com.fortify.cli.common.util` for common operations. Always prefer these over direct JDK/third-party equivalents. See the utilities guide for complete documentation. diff --git a/.github/instructions/style.instructions.md b/.github/instructions/style.instructions.md index 464d9c59d3d..ed915818a47 100644 --- a/.github/instructions/style.instructions.md +++ b/.github/instructions/style.instructions.md @@ -10,9 +10,9 @@ applyTo: 'fcli/**/*' **If you detect discrepancies** between these instructions and the actual codebase patterns, or discover conventions not documented here: -1. **Notify the user** about the discrepancy or missing documentation -2. **Suggest specific updates** to this instruction file -3. **Consider whether the discrepancy represents an intentional exception** (clarity over rules) or an outdated instruction +1. **Automatically update this file** to reflect the correct pattern +2. **Consider whether the discrepancy represents** an intentional exception (clarity over rules) or an outdated instruction +3. **Verify against current code** before documenting — never guess at intent ## General @@ -27,6 +27,18 @@ applyTo: 'fcli/**/*' - Use Streams for clear transformations/filtering, but favor simple for-loops if they are more readable or avoid unnecessary allocations. - Use `Optional` sparingly (avoid in hot inner loops or for simple nullable fields inside DTOs). +## Lombok Usage + +Always prefer Lombok annotations over hand-written boilerplate. Key rules beyond the obvious: + +- **`@Getter(lazy = true)`** — for expensive fields computed once on first access (thread-safe). +- **`@Getter(value = AccessLevel.PRIVATE)`** — restrict getter visibility; combine with `@Accessors(fluent = true)` for fluent data-holder APIs. +- **`@Builder` + `@RequiredArgsConstructor(access = AccessLevel.PRIVATE)`** — enforces construction via builder only; use `@Builder.Default` for non-null field defaults. +- **Jackson-deserialized descriptors (`@Reflectable`)** — do NOT add `@Builder`; use `@NoArgsConstructor` + `@Data` / `@Getter` (Jackson requires a no-arg constructor). +- **`@Data` with inheritance** — always add `@EqualsAndHashCode(callSuper = true)` explicitly. +- **`@Slf4j`** — preferred for new code; older code uses `LoggerFactory.getLogger(getClass())`. +- **Avoid** `@Value` (use Java `record`), `@With`, `@SuperBuilder` — not currently in use. + ## Imports & Formatting - Always use explicit imports; avoid wildcard imports. @@ -77,13 +89,29 @@ applyTo: 'fcli/**/*' - Record producers should remain side-effect free except for streaming output records. - Avoid static mutable state; prefer instance-level control (see recent refactor removing static collectors). +## Security Mindset + +Apply OWASP principles defensively — even in a CLI tool that processes data from trusted sources: + +- **Injection:** Although injecting user input into shell commands, file paths, SpEL/template expression is fairly common in fcli, always verify the potential security impact and apply proper safeguards if appropriate. +- **Isolation:** Avoid static mutable state that could be manipulated across command executions; prefer instance-level state or thread confinement. For multi-user server scenarios like MCP HTTP server, ensure proper data isolation. +- **Sensitive data exposure:** Don't log or print credentials, tokens, or secrets; use `saveSecuredFile()` / `readSecuredFile()` for persisted session credentials, and make sure that log masking is applied (see `LogMaskHelper`) +- **Path traversal:** Always resolve user-supplied paths against an expected root; use `FileUtils`'s zip-slip-protected extraction methods rather than raw `ZipEntry.getName()` +- **Deserialization:** Prefer Jackson with explicit type constraints; avoid `ObjectMapper.enableDefaultTyping()` or polymorphic `@JsonTypeInfo` with user-controlled type names +- **Dependency hygiene:** Don't add new dependencies without reviewing their transitive impact; prefer well-maintained libraries already on the classpath + ## AI Assistant Expectations -- Before large edits: scan related files (search by symbol) to avoid breaking contracts. -- After edits: run compile, address warnings if feasible. -- Never introduce commented-out code blocks; remove instead. -- Provide incremental, minimal diffs; do not reformat unrelated code. -- If refactoring signature changes, update all usages in same change. +- **Analyze before implementing:** Trace through existing abstractions with `semantic_search`, `grep_search`, and `vscode_listCodeUsages` before writing a single line. Understand the design intent, not just the surface syntax. +- **Reuse, don't reinvent:** Identify the most appropriate existing base class, mixin, or utility before creating new abstractions. Fcli has rich shared infrastructure — use it. +- **Apply SOLID principles:** Single Responsibility (each class does one thing), Open/Closed (extend via interfaces/abstractions, not modification), Liskov Substitution (subtypes must honor contracts), Interface Segregation (small focused interfaces), Dependency Inversion (depend on abstractions). +- **Separation of concerns:** Commands parse options and orchestrate; helpers/services contain business logic; writers handle output. Don't embed formatting in commands or business logic in writers. +- **Before large edits:** Scan related files (search by symbol) to avoid breaking contracts. +- **After edits:** Run `get_errors`, address any issues; build with Gradle if appropriate. +- **Never introduce commented-out code blocks;** remove instead. +- **Provide incremental, minimal diffs;** do not reformat unrelated code. +- **If refactoring changes a signature,** update all usages in the same change. +- **Self-update these instructions** when you discover a pattern, pitfall, or convention not yet documented here — make the edit immediately as part of the task. ## Pull Request Hygiene diff --git a/.github/instructions/utilities.instructions.md b/.github/instructions/utilities.instructions.md index cf2a51a84fb..a04a720db90 100644 --- a/.github/instructions/utilities.instructions.md +++ b/.github/instructions/utilities.instructions.md @@ -12,9 +12,9 @@ The `fcli-common` module provides utility classes in `com.fortify.cli.common.uti **If you discover utility classes, methods, or patterns** in `fcli-common/src/main/java/com/fortify/cli/common/util/` that are not documented here, or if documented utilities appear outdated/incorrect: -1. **Notify the user** about the missing or outdated documentation -2. **Suggest specific additions/updates** to this section -3. **Verify the utility's purpose and usage** in the codebase before documenting +1. **Automatically update this file** with the correct or missing documentation +2. **Verify the utility's purpose and usage** in the codebase before documenting +3. Keep additions concise — method signatures and a one-liner purpose are enough ## Environment & Configuration @@ -130,6 +130,50 @@ Global debug flag. - `isDebugEnabled()`, `setDebugEnabled()` +## Execution Context & Isolation + +These classes are in `com.fortify.cli.common.cli.util`, not `util/`, but are essential infrastructure. + +### `FcliExecutionContext` +Per-invocation execution frame. Always created/closed via `FcliExecutionContextHolder`. + +- Holds `UnirestContext` (fresh per external entry point), `FcliActionState`, and `FcliIsolationScope` +- Use try-with-resources: `try (var frame = FcliExecutionContextHolder.pushNew()) { ... }` +- `run.fcli` sub-commands reuse the parent frame; do not call `pushNew()` inside them + +### `FcliExecutionContextHolder` +Thread-local stack for nested execution frames. + +- `pushNew()` — creates a completely fresh context; use at top-level entry points (CLI, MCP, RPC) +- `push(ctx)` — pushes an existing context (e.g. child frames that share the parent's isolation scope) +- `current()` — returns the active context; throws if none is present +- Closing the returned `ContextFrame` pops and closes the context automatically + +### `FcliIsolationScope` +Groups related invocations under the same auth/session boundary. + +- Plain CLI: one new scope per invocation +- MCP stdio / RPC server: one scope for the server lifetime (all tool calls share sessions) +- MCP HTTP server: one scope **per authenticated identity** (full isolation between clients) +- Holds `transientSessionDescriptors` — in-memory sessions that are not persisted to disk + +### `FcliActionState` +Mutable bag of `global.*` action variables. + +- Each external CLI call and MCP/RPC tool call gets a **fresh** `FcliActionState` (no cross-call leakage) +- Imported action functions in MCP stdio/RPC share one instance across the server lifetime +- `run.fcli` sub-commands inherit and mutate the parent's state + +## Log Masking + +### `LogMaskHelper` (`com.fortify.cli.common.log`) +Singleton for registering values/patterns to mask in logs and stdio. + +- `LogMaskHelper.INSTANCE.registerValue(maskAnnotation, source, value)` — register a sensitive value using `@MaskValue` annotation semantics +- `registerPattern(level, pattern, replacement, types...)` — register a regex pattern for masking +- Session credentials are registered automatically by `AbstractSessionHelper`; CLI option values are registered by `FcliExecutionStrategy` when the field carries `@MaskValue` +- Do NOT log or print secrets directly; annotate sensitive option fields with `@MaskValue` instead + ## Usage Principles 1. **Prefer fcli utilities**: Use `EnvHelper.env()` over `System.getenv()`, `FcliDataHelper` over raw `Files` API for fcli data From 74e0b847852de01cfce5971ede16068b6da24b35 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 14 May 2026 13:11:07 +0200 Subject: [PATCH 23/55] chore: Null-safe handling --- .../cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java index 6fdf63f0505..fcc86265013 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java @@ -18,6 +18,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import org.apache.commons.lang3.StringUtils; @@ -102,7 +103,7 @@ public JsonNode buildAttributesNode(FoDEnums.AttributeTypes attrType, Map attr : effectiveMap.entrySet()) { var def = getDefinition(attr.getKey(), true); - if (attrType.getValue() == 0 || def.getAttributeTypeId() == attrType.getValue()) { + if (Objects.equals(attrType.getValue(), 0) || Objects.equals(def.getAttributeTypeId(), attrType.getValue())) { ObjectNode attrObj = JsonHelper.getObjectMapper().createObjectNode(); attrObj.put("id", def.getId()); attrObj.put("value", attr.getValue()); From 717a2ec2ddc3c2a50c7aeaa31d6761c406e2f965 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 14 May 2026 14:02:45 +0200 Subject: [PATCH 24/55] docs: Update JavaDoc, Copilot instructions --- .github/instructions/utilities.instructions.md | 5 +++-- .../fortify/cli/common/cli/util/FcliActionState.java | 6 +++++- .../cli/common/cli/util/FcliExecutionContext.java | 10 +++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/instructions/utilities.instructions.md b/.github/instructions/utilities.instructions.md index a04a720db90..e9afa3250c1 100644 --- a/.github/instructions/utilities.instructions.md +++ b/.github/instructions/utilities.instructions.md @@ -160,8 +160,9 @@ Groups related invocations under the same auth/session boundary. ### `FcliActionState` Mutable bag of `global.*` action variables. -- Each external CLI call and MCP/RPC tool call gets a **fresh** `FcliActionState` (no cross-call leakage) -- Imported action functions in MCP stdio/RPC share one instance across the server lifetime +- Each external CLI call and non-imported MCP/RPC tool call gets a **fresh** `FcliActionState` (no cross-call leakage) +- Imported action functions in **MCP stdio / RPC stdio** share one `FcliActionState` instance for the server lifetime +- Imported action functions in **MCP HTTP server** get a per-credentials-hash `FcliActionState` stored in the per-auth-scope `FcliIsolationScope` — persists across calls from the same user, isolated from other users - `run.fcli` sub-commands inherit and mutate the parent's state ## Log Masking diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java index 5c92481f254..81a02f7c30e 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java @@ -32,10 +32,14 @@ * next. *
  • MCP / RPC tool call (non-imported) — each tool call also gets a fresh * {@code FcliActionState}, keeping calls independent.
  • - *
  • Imported action functions (MCP stdio / RPC) — all invocations within the same + *
  • Imported action functions (MCP stdio / RPC stdio) — all invocations within the same * server instance share one {@code FcliActionState} instance. This is the mechanism that * lets one exported function set a {@code global.*} variable that a subsequent call to a * different exported function can read back.
  • + *
  • Imported action functions (MCP HTTP server) — each distinct authenticated identity + * (credentials hash) has its own {@code FcliActionState}, scoped to the same + * {@link FcliIsolationScope} as its transient session descriptor. {@code global.*} variables + * therefore persist across calls from the same user but are never shared with other users.
  • *
  • {@code run.fcli} sub-commands — executed within the parent's existing * {@link FcliExecutionContext}, so they see and can mutate the same * {@code FcliActionState} as the calling action step.
  • diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java index 2c3f2c0eb3f..c4dc625b477 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java @@ -41,9 +41,13 @@ *
  • Each external CLI invocation and each top-level MCP/RPC tool call starts with a * fresh {@code FcliActionState}, so {@code global.*} variables never leak between * independent calls.
  • - *
  • Imported functions (e.g. exported action functions served as MCP/RPC tools) share - * the same {@code FcliActionState} across calls within the lifetime of the same server instance, so - * that one function can set a variable that a later function call reads back.
  • + *
  • Imported functions in MCP stdio / RPC stdio share the same {@code FcliActionState} + * across all calls within the lifetime of the same server instance, so that one function + * can set a variable that a later function call reads back.
  • + *
  • Imported functions in the MCP HTTP server use a per-credentials-hash + * {@code FcliActionState} stored in the per-auth-scope {@link FcliIsolationScope}, + * so {@code global.*} variables persist across calls from the same authenticated + * identity but are isolated from other users.
  • *
  • Inner action invocations triggered via {@code run.fcli} inherit the parent frame's * {@code FcliActionState}, giving them read/write access to the same * {@code global.*} map as their caller.
  • From d6495684f57a54d01bea4917755f2977162fa08a Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 14 May 2026 15:38:35 +0200 Subject: [PATCH 25/55] chore: Isolation state expiry, SSC/FoD token validation --- .../mcp/cli/cmd/AgentMCPStartHttpCommand.java | 6 + .../mcp/helper/http/MCPServerHttpConfig.java | 11 ++ ...CPServerHttpSessionDescriptorResolver.java | 132 ++++++++++++++---- 3 files changed, 118 insertions(+), 31 deletions(-) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index 0d22fb3bad8..066cdf08973 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -16,6 +16,8 @@ import java.time.Duration; import java.util.ArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -84,6 +86,9 @@ public Integer call() throws Exception { ); var sessionDescriptorResolver = new MCPServerHttpSessionDescriptorResolver(config); + var scopeCleanupScheduler = Executors.newSingleThreadScheduledExecutor( + r -> new Thread(r, "mcp-http-scope-cleanup")); + sessionDescriptorResolver.scheduleCleanup(config.getIsolationScopeTtlInMillis(), scopeCleanupScheduler); var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, () -> sessionDescriptorResolver.getOrCreateFunctionFrame(FcliExecutionContextHolder.getMcpRequestAuthScopeKey())); var toolSpecs = new ArrayList(); @@ -136,6 +141,7 @@ public Integer call() throws Exception { Runtime.getRuntime().addShutdownHook(new Thread(() -> { transport.close(); asyncJobManager.shutdown(); + scopeCleanupScheduler.shutdown(); latch.countDown(); }, "mcp-http-shutdown-hook")); latch.await(); diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java index eefae71e511..4789e7dd075 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java @@ -35,12 +35,15 @@ @Data @NoArgsConstructor @Reflectable @JsonIgnoreProperties(ignoreUnknown = true) public class MCPServerHttpConfig { + private static final DateTimePeriodHelper TTL_PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.SECONDS, Period.HOURS); + private int port = 8080; private int workThreads = 10; private int progressThreads = 4; private int asyncBgThreads = AsyncJobManager.DEFAULT_BG_THREADS; private String jobSafeReturn = "25s"; private String progressInterval = "5s"; + private String isolationScopeTtl = "4h"; private List imports = new ArrayList<>(); private SscConfig ssc; private FoDConfig fod; @@ -107,12 +110,20 @@ public void validate(Path configPath) { throw new FcliSimpleException("HTTP MCP config must specify at least one imports entry"); } imports.forEach(this::validateImportPath); + getIsolationScopeTtlInMillis(); // validates isolationScopeTtl period string switch ( getProduct() ) { case ssc -> validateSscConfig(); case fod -> validateFoDConfig(); } } + @JsonIgnore + public long getIsolationScopeTtlInMillis() { + return StringUtils.isBlank(isolationScopeTtl) + ? 4 * 3600_000L + : TTL_PERIOD_HELPER.parsePeriodToMillis(isolationScopeTtl); + } + @JsonIgnore public Product getProduct() { var hasSsc = ssc != null; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 9a7efe44a0f..fc0694f6e11 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -23,6 +23,9 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.StringUtils; @@ -44,6 +47,7 @@ import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralUrlConfig; import com.fortify.cli.ssc._common.session.helper.ISSCUserCredentialsConfig; import com.fortify.cli.ssc._common.session.helper.SSCAndScanCentralSessionDescriptor; +import com.fortify.cli.ssc._common.session.helper.SSCSessionValidationHelper; import com.fortify.cli.ssc.access_control.helper.SSCTokenGetOrCreateResponse.SSCTokenData; import io.modelcontextprotocol.common.McpTransportContext; @@ -62,37 +66,33 @@ public final class MCPServerHttpSessionDescriptorResolver { private static final String FOD_CLIENT_ID_KEY = "client-id"; private static final String FOD_CLIENT_SECRET_KEY = "client-secret"; - private static final int MAX_SESSION_DESCRIPTOR_CACHE_SIZE = 256; private static final String[] DEFAULT_FOD_SCOPES = new String[] {"api-tenant"}; private final MCPServerHttpConfig config; - private final Map sessionDescriptorCache = new LinkedHashMap<>(16, 0.75f, true) { - private static final long serialVersionUID = 1L; + private final ConcurrentHashMap isolationScopeCache = new ConcurrentHashMap<>(); - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_SESSION_DESCRIPTOR_CACHE_SIZE; + private static final class IsolationScopeEntry { + final FcliIsolationScope scope; + volatile long lastAccessTime; + + IsolationScopeEntry(FcliIsolationScope scope) { + this.scope = scope; + this.lastAccessTime = System.currentTimeMillis(); } - }; - private final Map isolationScopeCache = new LinkedHashMap<>(16, 0.75f, true) { - private static final long serialVersionUID = 1L; - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_SESSION_DESCRIPTOR_CACHE_SIZE; + void updateLastAccess() { + lastAccessTime = System.currentTimeMillis(); } - }; - private static final class FunctionContextState { - private final FcliActionState actionState = new FcliActionState(); - } - public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext transportContext) { - var cacheKey = createAuthCacheKey(transportContext); - synchronized (sessionDescriptorCache) { - return sessionDescriptorCache.computeIfAbsent(cacheKey, ignored -> createSessionDescriptor(transportContext)); + boolean isExpired(long ttlMillis) { + return System.currentTimeMillis() - lastAccessTime > ttlMillis; } } + private static final class FunctionContextState { + private final FcliActionState actionState = new FcliActionState(); + } + /** * Pushes a new {@link FcliExecutionContext} (fresh {@code UnirestContext}) for the given * auth scope key and returns the associated {@link FcliExecutionContextHolder.ContextFrame}. @@ -100,40 +100,110 @@ public ISessionDescriptor getOrCreateSessionDescriptor(McpTransportContext trans * {@code global.*} action variables persist within the same authenticated identity. */ public FcliExecutionContextHolder.ContextFrame getOrCreateFunctionFrame(String authScopeKey) { - var isolationScope = getOrCreateIsolationScope(authScopeKey); + var isolationScope = getExistingIsolationScope(authScopeKey); var actionState = isolationScope.getOrCreateScopedState(FunctionContextState.class, FunctionContextState::new).actionState; return FcliExecutionContextHolder.push(new FcliExecutionContext(isolationScope, actionState)); } + /** + * Gets or creates the {@link FcliIsolationScope} for the request's authenticated identity, + * then validates and refreshes the session token before returning. + * For FoD, a new OAuth token is obtained if the cached token has expired. + * For SSC, the token is actively validated against SSC on every request; a + * {@link com.fortify.cli.common.exception.FcliSimpleException} is thrown if the token + * is invalid or has been revoked. + */ public FcliIsolationScope getOrCreateIsolationScope(McpTransportContext transportContext) { var authScopeKey = createAuthCacheKey(transportContext); - synchronized (isolationScopeCache) { - return isolationScopeCache.computeIfAbsent(authScopeKey, ignored -> createIsolationScope(authScopeKey, transportContext)); + var entry = isolationScopeCache.get(authScopeKey); + if ( entry == null ) { + var newEntry = new IsolationScopeEntry(createIsolationScope(authScopeKey, transportContext)); + var existing = isolationScopeCache.putIfAbsent(authScopeKey, newEntry); + entry = existing != null ? existing : newEntry; } + entry.updateLastAccess(); + validateAndRefreshSession(entry, transportContext); + return entry.scope; } public String getAuthScopeKey(McpTransportContext transportContext) { return createAuthCacheKey(transportContext); } - private FcliIsolationScope getOrCreateIsolationScope(String authScopeKey) { - synchronized (isolationScopeCache) { - var result = isolationScopeCache.get(authScopeKey); - if ( result == null ) { - throw new IllegalStateException("No isolation scope found for auth scope key"); - } - return result; + /** + * Schedules periodic eviction of isolation scopes that have not been accessed within + * {@code ttlMillis}. The cleanup interval is {@code max(1 minute, ttlMillis / 4)}. + */ + public void scheduleCleanup(long ttlMillis, ScheduledExecutorService scheduler) { + var periodMillis = Math.max(60_000L, ttlMillis / 4); + scheduler.scheduleWithFixedDelay( + () -> evictExpiredScopes(ttlMillis), + periodMillis, periodMillis, TimeUnit.MILLISECONDS); + } + + private void evictExpiredScopes(long ttlMillis) { + isolationScopeCache.entrySet().removeIf(e -> e.getValue().isExpired(ttlMillis)); + } + + private FcliIsolationScope getExistingIsolationScope(String authScopeKey) { + var entry = isolationScopeCache.get(authScopeKey); + if ( entry == null ) { + throw new IllegalStateException("No isolation scope found for auth scope key"); } + return entry.scope; } private FcliIsolationScope createIsolationScope(String authScopeKey, McpTransportContext transportContext) { var result = new FcliIsolationScope(); result.setMcpRequestAuthScopeKey(authScopeKey); - result.setTransientSessionDescriptor(getOrCreateSessionDescriptor(transportContext)); + result.setTransientSessionDescriptor(createSessionDescriptor(transportContext)); return result; } + /** + * Validates and if necessary refreshes the session token for the given entry. + * Synchronized on the entry to prevent concurrent refreshes for the same user. + */ + private void validateAndRefreshSession(IsolationScopeEntry entry, McpTransportContext transportContext) { + synchronized (entry) { + var descriptors = entry.scope.getTransientSessionDescriptors(); + if ( descriptors.isEmpty() ) { + return; + } + var descriptor = descriptors.values().iterator().next(); + if ( descriptor instanceof FoDSessionDescriptor fodDescriptor ) { + refreshFoDTokenIfExpired(fodDescriptor, transportContext); + } else if ( descriptor instanceof SSCAndScanCentralSessionDescriptor sscDescriptor ) { + validateSscToken(sscDescriptor); + } + } + } + + private void refreshFoDTokenIfExpired(FoDSessionDescriptor descriptor, McpTransportContext transportContext) { + if ( descriptor.hasActiveCachedTokenResponse() ) { + return; + } + var auth = parseAuthHeader(transportContext); + var fodConfig = config.getFod(); + var urlConfig = UrlConfig.builderFromConnectionConfig(fodConfig) + .url(FoDProductHelper.INSTANCE.getApiUrl(fodConfig.getUrl())) + .build(); + descriptor.setCachedTokenResponse(createFoDTokenResponse(auth, urlConfig)); + } + + private void validateSscToken(SSCAndScanCentralSessionDescriptor descriptor) { + var token = descriptor.getActiveSSCToken(); + if ( token == null ) { + throw new FcliSimpleException("SSC session token has expired; please provide a valid token in the %s header", HEADER_AUTH_SSC); + } + var status = SSCSessionValidationHelper.checkTokenStatus(descriptor.getSscUrlConfig(), token); + if ( !status.valid() ) { + throw new FcliSimpleException("SSC session token is invalid or has been revoked; please provide a valid token in the %s header", HEADER_AUTH_SSC); + } + descriptor.setSscTokenData(status.tokenData()); + } + String createAuthCacheKey(McpTransportContext transportContext) { var auth = parseAuthHeader(transportContext); return switch (auth.product()) { From 91b904cb8c76f19ad280fd99816bfa8a8fde9c52 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 14 May 2026 16:05:22 +0200 Subject: [PATCH 26/55] chore: Properly isolate and clean up stored variables --- .../mcp/cli/cmd/AgentMCPStartHttpCommand.java | 1 + ...CPServerHttpSessionDescriptorResolver.java | 37 ++++++++++++++++++- .../common/cli/util/FcliIsolationScope.java | 6 +++ .../common/variable/FcliVariableHelper.java | 7 ++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index 066cdf08973..3962195a814 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -142,6 +142,7 @@ public Integer call() throws Exception { transport.close(); asyncJobManager.shutdown(); scopeCleanupScheduler.shutdown(); + sessionDescriptorResolver.shutdown(); latch.countDown(); }, "mcp-http-shutdown-hook")); latch.await(); diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index fc0694f6e11..5f2a235f85d 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -13,6 +13,8 @@ package com.fortify.cli.agent.mcp.helper.http; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; @@ -36,6 +38,8 @@ import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.rest.unirest.config.UrlConfig; import com.fortify.cli.common.session.helper.ISessionDescriptor; +import com.fortify.cli.common.util.FcliDataHelper; +import com.fortify.cli.common.util.FileUtils; import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; import com.fortify.cli.fod._common.session.helper.FoDSessionDescriptor; import com.fortify.cli.fod._common.session.helper.oauth.FoDOAuthHelper; @@ -52,8 +56,10 @@ import io.modelcontextprotocol.common.McpTransportContext; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor +@Slf4j public final class MCPServerHttpSessionDescriptorResolver { public static final String HEADER_AUTH_SSC = "X-AUTH-SSC"; public static final String HEADER_AUTH_FOD = "X-AUTH-FOD"; @@ -69,6 +75,7 @@ public final class MCPServerHttpSessionDescriptorResolver { private static final String[] DEFAULT_FOD_SCOPES = new String[] {"api-tenant"}; private final MCPServerHttpConfig config; + private final Path mcpScopedVarsRootPath = FcliDataHelper.getFcliStatePath().resolve("mcp-http-vars"); private final ConcurrentHashMap isolationScopeCache = new ConcurrentHashMap<>(); private static final class IsolationScopeEntry { @@ -142,8 +149,24 @@ public void scheduleCleanup(long ttlMillis, ScheduledExecutorService scheduler) periodMillis, periodMillis, TimeUnit.MILLISECONDS); } + /** + * Shuts down the resolver: deletes all per-scope variable directories and the shared + * root directory. Should be called from the server shutdown hook. + */ + public void shutdown() { + isolationScopeCache.values().forEach(e -> deleteDirQuietly(e.scope.getScopedVarsPath())); + isolationScopeCache.clear(); + deleteDirQuietly(mcpScopedVarsRootPath); + } + private void evictExpiredScopes(long ttlMillis) { - isolationScopeCache.entrySet().removeIf(e -> e.getValue().isExpired(ttlMillis)); + isolationScopeCache.entrySet().removeIf(e -> { + if ( e.getValue().isExpired(ttlMillis) ) { + deleteDirQuietly(e.getValue().scope.getScopedVarsPath()); + return true; + } + return false; + }); } private FcliIsolationScope getExistingIsolationScope(String authScopeKey) { @@ -158,6 +181,7 @@ private FcliIsolationScope createIsolationScope(String authScopeKey, McpTranspor var result = new FcliIsolationScope(); result.setMcpRequestAuthScopeKey(authScopeKey); result.setTransientSessionDescriptor(createSessionDescriptor(transportContext)); + result.setScopedVarsPath(mcpScopedVarsRootPath.resolve(authScopeKey.replace("|", "_"))); return result; } @@ -204,6 +228,17 @@ private void validateSscToken(SSCAndScanCentralSessionDescriptor descriptor) { descriptor.setSscTokenData(status.tokenData()); } + private void deleteDirQuietly(Path absolutePath) { + if ( absolutePath == null || !Files.exists(absolutePath) ) { + return; + } + try { + FileUtils.deleteRecursive(absolutePath); + } catch ( Exception e ) { + log.warn("Failed to delete scoped vars directory {}: {}", absolutePath, e.getMessage()); + } + } + String createAuthCacheKey(McpTransportContext transportContext) { var auth = parseAuthHeader(transportContext); return switch (auth.product()) { diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java index b21cd0315ff..7303b32660b 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.common.cli.util; +import java.nio.file.Path; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; @@ -46,6 +47,7 @@ */ public final class FcliIsolationScope { @Getter private volatile String mcpRequestAuthScopeKey; + @Getter private volatile Path scopedVarsPath; @Getter private final Map transientSessionDescriptors = new ConcurrentHashMap<>(); private final Map, Object> scopedStates = new ConcurrentHashMap<>(); @@ -73,6 +75,10 @@ public void setMcpRequestAuthScopeKey(String mcpRequestAuthScopeKey) { this.mcpRequestAuthScopeKey = mcpRequestAuthScopeKey; } + public void setScopedVarsPath(Path scopedVarsPath) { + this.scopedVarsPath = scopedVarsPath; + } + @SuppressWarnings("unchecked") public T getOrCreateScopedState(Class type, Supplier supplier) { return (T)scopedStates.computeIfAbsent(type, ignored -> supplier.get()); diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/variable/FcliVariableHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/variable/FcliVariableHelper.java index 76253f6939e..776887cbc11 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/variable/FcliVariableHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/variable/FcliVariableHelper.java @@ -29,6 +29,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.crypto.helper.EncryptionHelper; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.JsonHelper; @@ -63,6 +64,12 @@ public static class VariableDescriptor extends JsonNodeHolder { } public static final Path getVariablesPath() { + if ( FcliExecutionContextHolder.stackDepth() > 0 ) { + var scopedVarsPath = FcliExecutionContextHolder.current().getIsolationScope().getScopedVarsPath(); + if ( scopedVarsPath != null ) { + return scopedVarsPath; + } + } return FcliDataHelper.getFcliStatePath().resolve("vars"); } From d00167e894ace2631233e6731f0693ed5912ee8f Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 14 May 2026 16:18:02 +0200 Subject: [PATCH 27/55] chore: Update sample MCP HTTP config files --- .../agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java | 1 - .../agent/mcp/helper/http/MCPServerHttpConfig.java | 12 ++++++++++++ .../cli/agent/mcp/config/mcp-http-config-fod.yaml | 10 ++++++++++ .../cli/agent/mcp/config/mcp-http-config-ssc.yaml | 10 ++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index 3962195a814..8189d402074 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -17,7 +17,6 @@ import java.util.ArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; import com.fasterxml.jackson.databind.DeserializationFeature; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java index 4789e7dd075..1e1f01684ac 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java @@ -32,6 +32,18 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +/** + * Configuration for the HTTP MCP server, loaded from a YAML file. + * + *

    IMPORTANT — keep sample configs in sync: whenever a field is added, renamed, + * removed, or has its default value changed, update both sample templates: + *

      + *
    • {@code src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml}
    • + *
    • {@code src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml}
    • + *
    + * Optional fields should appear as commented-out lines showing the default value and a brief + * description. Required fields (e.g. {@code url}) should appear uncommented with a placeholder. + */ @Data @NoArgsConstructor @Reflectable @JsonIgnoreProperties(ignoreUnknown = true) public class MCPServerHttpConfig { diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml index 460281aab0a..1ad02b65e8a 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml @@ -1,6 +1,16 @@ port: 8080 imports: - actions/http-fod.yaml + +# Optional settings (shown with default values): +# workThreads: 10 # Number of threads for handling MCP tool call requests +# progressThreads: 4 # Number of threads for streaming progress updates +# asyncBgThreads: 2 # Number of background threads for async job processing +# jobSafeReturn: 25s # Max time to wait for a job result before returning a job ID +# progressInterval: 5s # Interval between progress update checks +# isolationScopeTtl: 4h # Idle timeout for per-user session/variable state; state is +# # discarded and variables are deleted after this period of inactivity + fod: url: https://api.ams.fortify.com connectTimeout: 30s diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml index af7a740ae26..5aa25d6cf4f 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml @@ -1,6 +1,16 @@ port: 8080 imports: - actions/http-ssc.yaml + +# Optional settings (shown with default values): +# workThreads: 10 # Number of threads for handling MCP tool call requests +# progressThreads: 4 # Number of threads for streaming progress updates +# asyncBgThreads: 2 # Number of background threads for async job processing +# jobSafeReturn: 25s # Max time to wait for a job result before returning a job ID +# progressInterval: 5s # Interval between progress update checks +# isolationScopeTtl: 4h # Idle timeout for per-user session/variable state; state is +# # discarded and variables are deleted after this period of inactivity + ssc: url: https://ssc.example.com connectTimeout: 30s From 92e5963b509f1028be0fc5f65e67283a62ba4b86 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 14 May 2026 16:31:56 +0200 Subject: [PATCH 28/55] chore: Update thread defaults & descriptions --- .../mcp/helper/http/MCPServerHttpConfig.java | 2 +- .../cli/agent/i18n/AgentMessages.properties | 10 +++++++++- .../agent/mcp/config/mcp-http-config-fod.yaml | 17 ++++++++++++++++- .../agent/mcp/config/mcp-http-config-ssc.yaml | 17 ++++++++++++++++- .../cli/util/i18n/UtilMessages.properties | 3 +++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java index 1e1f01684ac..deb1b4a9a9f 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java @@ -50,7 +50,7 @@ public class MCPServerHttpConfig { private static final DateTimePeriodHelper TTL_PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.SECONDS, Period.HOURS); private int port = 8080; - private int workThreads = 10; + private int workThreads = 20; private int progressThreads = 4; private int asyncBgThreads = AsyncJobManager.DEFAULT_BG_THREADS; private String jobSafeReturn = "25s"; diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties index aea06e3ac0e..fe925263d49 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties @@ -5,7 +5,15 @@ fcli.agent.usage.description = Manage AI-related functionality like MCP servers fcli.agent.mcp.usage.header = (PREVIEW) Manage fcli MCP server commands for AI assistants fcli.agent.mcp.usage.description = Start fcli MCP servers for AI assistants, and generate HTTP MCP server config templates. fcli.agent.mcp.start-stdio.usage.header = (PREVIEW) Start fcli MCP server on stdio for AI integration -fcli.agent.mcp.start-stdio.usage.description = Start the fcli MCP server over stdio. This command exposes fcli module commands and/or imported action functions as MCP tools to AI clients. +fcli.agent.mcp.start-stdio.usage.description = Start the fcli MCP server over stdio. This command exposes fcli module commands and/or \ + imported action functions as MCP tools to AI clients.%n\ + %nTHREAD MODEL:%n\ + - Work threads (--work-threads): execute MCP tool calls. Each concurrent tool call occupies one thread for its\n\ + full duration. Size to the maximum number of tool calls the AI may invoke in parallel.%n\ + - Progress threads (--progress-threads): poll progress for long-running jobs at regular intervals. One thread\n\ + is consumed per active long-running job during each poll. The default of 4 is sufficient for most use cases.%n\ + - Async background threads (--async-bg-threads): run background async streaming jobs (e.g. run.fcli steps\n\ + with streaming output). Increase if actions make heavy use of async streaming.%n fcli.agent.mcp.start-stdio.module = Fcli module to expose through this MCP server instance. fcli.agent.mcp.start-stdio.import = Action YAML files to import. Exported functions are registered as MCP tools or resources based on function metadata. fcli.agent.mcp.start-stdio.work-threads = Number of worker threads used to execute MCP tool jobs concurrently. Increase for higher parallelism if AI invokes multiple tools simultaneously. diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml index 1ad02b65e8a..52580e91404 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml @@ -2,8 +2,23 @@ port: 8080 imports: - actions/http-fod.yaml +# THREAD MODEL +# The HTTP MCP server uses three independent thread pools: +# +# workThreads -- handles MCP tool call requests. Each simultaneous tool call +# from any user occupies one work thread for its full duration. +# Size this to: expected_concurrent_users * max_parallel_tool_calls_per_user. +# +# progressThreads -- polls progress for long-running jobs at regular intervals. +# One thread is consumed per active long-running job during each poll cycle. +# 4 threads is sufficient for most deployments. +# +# asyncBgThreads -- runs background async streaming jobs (e.g. run.fcli steps with streaming +# output). Each active async streaming job occupies one background thread. +# Increase if actions make heavy use of async streaming. + # Optional settings (shown with default values): -# workThreads: 10 # Number of threads for handling MCP tool call requests +# workThreads: 20 # Number of threads for handling MCP tool call requests # progressThreads: 4 # Number of threads for streaming progress updates # asyncBgThreads: 2 # Number of background threads for async job processing # jobSafeReturn: 25s # Max time to wait for a job result before returning a job ID diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml index 5aa25d6cf4f..0bbf9691e65 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml @@ -2,8 +2,23 @@ port: 8080 imports: - actions/http-ssc.yaml +# THREAD MODEL +# The HTTP MCP server uses three independent thread pools: +# +# workThreads -- handles MCP tool call requests. Each simultaneous tool call +# from any user occupies one work thread for its full duration. +# Size this to: expected_concurrent_users * max_parallel_tool_calls_per_user. +# +# progressThreads -- polls progress for long-running jobs at regular intervals. +# One thread is consumed per active long-running job during each poll cycle. +# 4 threads is sufficient for most deployments. +# +# asyncBgThreads -- runs background async streaming jobs (e.g. run.fcli steps with streaming +# output). Each active async streaming job occupies one background thread. +# Increase if actions make heavy use of async streaming. + # Optional settings (shown with default values): -# workThreads: 10 # Number of threads for handling MCP tool call requests +# workThreads: 20 # Number of threads for handling MCP tool call requests # progressThreads: 4 # Number of threads for streaming progress updates # asyncBgThreads: 2 # Number of background threads for async job processing # jobSafeReturn: 25s # Max time to wait for a job result before returning a job ID diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 9e3909f8057..b3f97b9cfc2 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -103,6 +103,9 @@ fcli.util.rpc-server.start.usage.description = %nThe JSON-RPC server is currentl designed for LLM integration, the RPC server exposes a smaller set of general-purpose methods suitable for \ programmatic access from IDE plugins.%n%n\ The server reads JSON-RPC 2.0 requests from stdin and writes responses to stdout, one JSON object per line. \ + %n%nTHREAD MODEL: The RPC server uses a single async background thread pool (--async-bg-threads, default: 4). \ + Each active async streaming job occupies one background thread. Increase if IDE clients submit many \ + concurrent async commands.%n\ %n\ %nAVAILABLE RPC METHODS\ %n\ From 18671384ebfc72d5f1bc1ec3c59d607b983eabe6 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 18 May 2026 10:47:51 +0200 Subject: [PATCH 29/55] chore: Set thread pool request executor --- .../mcp/helper/http/JdkHttpServerMcpStatelessTransport.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java index 307808dd0ac..791f8be098e 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -17,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.concurrent.Executors; import java.util.stream.Collectors; import com.sun.net.httpserver.HttpExchange; @@ -48,6 +49,7 @@ public class JdkHttpServerMcpStatelessTransport implements McpStatelessServerTra public JdkHttpServerMcpStatelessTransport(int port, String mcpEndpoint, McpJsonMapper jsonMapper) throws IOException { this.httpServer = HttpServer.create(new InetSocketAddress(port), 0); + this.httpServer.setExecutor(Executors.newCachedThreadPool()); this.mcpEndpoint = normalizeEndpoint(mcpEndpoint); this.jsonMapper = jsonMapper; this.httpServer.createContext(this.mcpEndpoint, this::handleExchange); From 0adb7109f31a0a6c474cc9a5e64fcdfdd07ee7eb Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 18 May 2026 11:47:13 +0200 Subject: [PATCH 30/55] chore: Restructure config, add TLS, bind address, request size --- .../mcp/cli/cmd/AgentMCPStartHttpCommand.java | 18 ++-- .../JdkHttpServerMcpStatelessTransport.java | 61 +++++++++++- .../mcp/helper/http/MCPServerHttpConfig.java | 95 +++++++++++++++---- .../agent/mcp/config/mcp-http-config-fod.yaml | 50 ++++++---- .../agent/mcp/config/mcp-http-config-ssc.yaml | 50 ++++++---- .../unit/MCPServerHttpConfigLoaderTest.java | 2 +- 6 files changed, 204 insertions(+), 72 deletions(-) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index 8189d402074..e1b095e58f8 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -66,8 +66,8 @@ public Integer call() throws Exception { var config = MCPServerHttpConfigLoader.load(configPath); - var safeReturnMillis = PERIOD_HELPER.parsePeriodToMillis(config.getJobSafeReturn()); - var progressIntervalMillis = PERIOD_HELPER.parsePeriodToMillis(config.getProgressInterval()); + var safeReturnMillis = PERIOD_HELPER.parsePeriodToMillis(config.getJobs().getSafeReturn()); + var progressIntervalMillis = PERIOD_HELPER.parsePeriodToMillis(config.getJobs().getProgressInterval()); if ( safeReturnMillis <= 0 ) { safeReturnMillis = 25000; } @@ -75,10 +75,10 @@ public Integer call() throws Exception { progressIntervalMillis = 500; } - var asyncJobManager = new AsyncJobManager(AsyncJobManager.Config.builder().bgThreads(config.getAsyncBgThreads()).build()); + var asyncJobManager = new AsyncJobManager(AsyncJobManager.Config.builder().bgThreads(config.getJobs().getAsyncBgThreads()).build()); var jobManager = new MCPJobManager( - config.getWorkThreads(), - config.getProgressThreads(), + config.getJobs().getWorkThreads(), + config.getJobs().getProgressThreads(), safeReturnMillis, progressIntervalMillis, asyncJobManager @@ -87,7 +87,7 @@ public Integer call() throws Exception { var sessionDescriptorResolver = new MCPServerHttpSessionDescriptorResolver(config); var scopeCleanupScheduler = Executors.newSingleThreadScheduledExecutor( r -> new Thread(r, "mcp-http-scope-cleanup")); - sessionDescriptorResolver.scheduleCleanup(config.getIsolationScopeTtlInMillis(), scopeCleanupScheduler); + sessionDescriptorResolver.scheduleCleanup(config.getJobs().getIsolationScopeTtlInMillis(), scopeCleanupScheduler); var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, () -> sessionDescriptorResolver.getOrCreateFunctionFrame(FcliExecutionContextHolder.getMcpRequestAuthScopeKey())); var toolSpecs = new ArrayList(); @@ -118,7 +118,7 @@ public Integer call() throws Exception { } var objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - var transport = new JdkHttpServerMcpStatelessTransport(config.getPort(), "/mcp", new JacksonMcpJsonMapper(objectMapper)); + var transport = new JdkHttpServerMcpStatelessTransport(config.getServer(), "/mcp", new JacksonMcpJsonMapper(objectMapper)); var serverBuilder = McpServer.sync(transport) .serverInfo("fcli", FcliBuildProperties.INSTANCE.getFcliVersion()) @@ -133,8 +133,8 @@ public Integer call() throws Exception { log.debug("Initialized HTTP MCP server instance: {}", mcpServer); transport.start(); - log.info("Fcli HTTP MCP server running on port {} for product {}", config.getPort(), config.getProduct()); - System.err.println("Fcli HTTP MCP server running on port " + config.getPort() + " endpoint /mcp. Hit Ctrl-C to exit."); + log.info("Fcli HTTP MCP server running on port {} for product {}", config.getServer().getPort(), config.getProduct()); + System.err.println("Fcli HTTP MCP server running on port " + config.getServer().getPort() + " endpoint /mcp. Hit Ctrl-C to exit."); var latch = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java index 791f8be098e..fd9c19f7233 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -13,15 +13,26 @@ package com.fortify.cli.agent.mcp.helper.http; import java.io.IOException; -import java.net.InetSocketAddress; +import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.GeneralSecurityException; +import java.security.KeyStore; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; import java.util.stream.Collectors; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; + +import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfig.ServerConfig; +import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfig.TlsConfig; +import com.fortify.cli.common.exception.FcliSimpleException; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; @@ -44,11 +55,21 @@ public class JdkHttpServerMcpStatelessTransport implements McpStatelessServerTra private final HttpServer httpServer; private final String mcpEndpoint; private final McpJsonMapper jsonMapper; + private final long maxRequestBodyBytes; private volatile McpStatelessServerHandler mcpHandler; private volatile boolean closing; - public JdkHttpServerMcpStatelessTransport(int port, String mcpEndpoint, McpJsonMapper jsonMapper) throws IOException { - this.httpServer = HttpServer.create(new InetSocketAddress(port), 0); + public JdkHttpServerMcpStatelessTransport(ServerConfig serverConfig, String mcpEndpoint, McpJsonMapper jsonMapper) throws IOException { + this.maxRequestBodyBytes = serverConfig.getMaxRequestBodyBytes(); + var address = serverConfig.getInetSocketAddress(); + var tls = serverConfig.getTls(); + if ( tls != null ) { + var httpsServer = HttpsServer.create(address, 0); + httpsServer.setHttpsConfigurator(new HttpsConfigurator(buildSslContext(tls))); + this.httpServer = httpsServer; + } else { + this.httpServer = HttpServer.create(address, 0); + } this.httpServer.setExecutor(Executors.newCachedThreadPool()); this.mcpEndpoint = normalizeEndpoint(mcpEndpoint); this.jsonMapper = jsonMapper; @@ -104,7 +125,9 @@ private void handleExchange(HttpExchange exchange) throws IOException { "headers", exchange.getRequestHeaders().entrySet().stream() .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, e -> List.copyOf(e.getValue()))))); try { - var body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + var bodyBytes = readRequestBody(exchange); + if ( bodyBytes == null ) { return; } // response already sent (body too large) + var body = new String(bodyBytes, StandardCharsets.UTF_8); var message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); if ( message instanceof McpSchema.JSONRPCRequest request ) { var response = mcpHandler.handleRequest(transportContext, request) @@ -137,6 +160,36 @@ private void handleExchange(HttpExchange exchange) throws IOException { } } + private byte[] readRequestBody(HttpExchange exchange) throws IOException { + if ( maxRequestBodyBytes <= 0 ) { + return exchange.getRequestBody().readAllBytes(); + } + // Read one extra byte to detect oversized bodies without loading them fully + var limit = (int) Math.min(maxRequestBodyBytes + 1, Integer.MAX_VALUE); + var bytes = exchange.getRequestBody().readNBytes(limit); + if ( bytes.length > maxRequestBodyBytes ) { + sendPlainError(exchange, 413, "Request entity too large"); + return null; + } + return bytes; + } + + private static SSLContext buildSslContext(TlsConfig tls) { + try { + var keyStore = KeyStore.getInstance(tls.getKeystoreType()); + try ( InputStream is = Files.newInputStream(tls.getKeystoreFile()) ) { + keyStore.load(is, tls.getKeystorePassword().toCharArray()); + } + var kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, tls.getEffectiveKeyPassword()); + var sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), null, null); + return sslContext; + } catch (GeneralSecurityException | IOException e) { + throw new FcliSimpleException("Failed to initialize TLS from keystore: " + e.getMessage(), e); + } + } + private String normalizeEndpoint(String endpoint) { if ( endpoint == null || endpoint.isBlank() ) { return "/mcp"; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java index deb1b4a9a9f..172c90b9ed5 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.agent.mcp.helper.http; +import java.net.InetSocketAddress; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -47,15 +48,8 @@ @Data @NoArgsConstructor @Reflectable @JsonIgnoreProperties(ignoreUnknown = true) public class MCPServerHttpConfig { - private static final DateTimePeriodHelper TTL_PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.SECONDS, Period.HOURS); - - private int port = 8080; - private int workThreads = 20; - private int progressThreads = 4; - private int asyncBgThreads = AsyncJobManager.DEFAULT_BG_THREADS; - private String jobSafeReturn = "25s"; - private String progressInterval = "5s"; - private String isolationScopeTtl = "4h"; + private ServerConfig server = new ServerConfig(); + private JobsConfig jobs = new JobsConfig(); private List imports = new ArrayList<>(); private SscConfig ssc; private FoDConfig fod; @@ -67,6 +61,58 @@ public enum Product { fod } + @Data @NoArgsConstructor @Reflectable + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ServerConfig { + private int port = 8080; + private String bindAddress; + private long maxRequestBodyBytes = -1; + private TlsConfig tls; + + @JsonIgnore + public InetSocketAddress getInetSocketAddress() { + if ( StringUtils.isBlank(bindAddress) ) { + return new InetSocketAddress(port); + } + return new InetSocketAddress(bindAddress, port); + } + } + + @Data @NoArgsConstructor @Reflectable + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TlsConfig { + private Path keystoreFile; + private String keystorePassword; + private String keyPassword; + private String keystoreType = "PKCS12"; + + @JsonIgnore + public char[] getEffectiveKeyPassword() { + var pwd = keyPassword != null ? keyPassword : keystorePassword; + return pwd != null ? pwd.toCharArray() : new char[0]; + } + } + + @Data @NoArgsConstructor @Reflectable + @JsonIgnoreProperties(ignoreUnknown = true) + public static class JobsConfig { + private static final DateTimePeriodHelper TTL_PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.SECONDS, Period.HOURS); + + private int workThreads = 20; + private int progressThreads = 4; + private int asyncBgThreads = AsyncJobManager.DEFAULT_BG_THREADS; + private String safeReturn = "25s"; + private String progressInterval = "5s"; + private String isolationScopeTtl = "4h"; + + @JsonIgnore + public long getIsolationScopeTtlInMillis() { + return StringUtils.isBlank(isolationScopeTtl) + ? 4 * 3600_000L + : TTL_PERIOD_HELPER.parsePeriodToMillis(isolationScopeTtl); + } + } + @Data @NoArgsConstructor @Reflectable @JsonIgnoreProperties(ignoreUnknown = true) public abstract static class ConnectionConfig implements IConnectionConfig { @@ -118,24 +164,18 @@ protected int getDefaultSocketTimeoutInMillis() { public void validate(Path configPath) { this.configPath = configPath; + validateServerConfig(); if ( imports == null || imports.isEmpty() ) { throw new FcliSimpleException("HTTP MCP config must specify at least one imports entry"); } imports.forEach(this::validateImportPath); - getIsolationScopeTtlInMillis(); // validates isolationScopeTtl period string + jobs.getIsolationScopeTtlInMillis(); // validates isolationScopeTtl period string switch ( getProduct() ) { case ssc -> validateSscConfig(); case fod -> validateFoDConfig(); } } - @JsonIgnore - public long getIsolationScopeTtlInMillis() { - return StringUtils.isBlank(isolationScopeTtl) - ? 4 * 3600_000L - : TTL_PERIOD_HELPER.parsePeriodToMillis(isolationScopeTtl); - } - @JsonIgnore public Product getProduct() { var hasSsc = ssc != null; @@ -167,13 +207,32 @@ private void validateImportPath(String importPath) { } private Path resolveImportPath(String importPath) { - var path = Path.of(importPath); + return resolveRelativePath(Path.of(importPath)); + } + + private Path resolveRelativePath(Path path) { if ( path.isAbsolute() ) { return path.normalize(); } return configPath.getParent().resolve(path).normalize(); } + private void validateServerConfig() { + var tls = server.getTls(); + if ( tls == null ) { return; } + if ( tls.getKeystoreFile() == null ) { + throw new FcliSimpleException("HTTP MCP config server.tls.keystoreFile must be specified"); + } + var resolvedKeystoreFile = resolveRelativePath(tls.getKeystoreFile()); + if ( !resolvedKeystoreFile.toFile().isFile() ) { + throw new FcliSimpleException("HTTP MCP config server.tls.keystoreFile not found: " + resolvedKeystoreFile); + } + tls.setKeystoreFile(resolvedKeystoreFile); + if ( StringUtils.isBlank(tls.getKeystorePassword()) ) { + throw new FcliSimpleException("HTTP MCP config server.tls.keystorePassword must be specified"); + } + } + private void validateSscConfig() { if ( fod != null ) { throw new FcliSimpleException("HTTP MCP config must not specify both ssc and fod sections"); diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml index 52580e91404..223990ba698 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml @@ -1,29 +1,39 @@ -port: 8080 +server: + port: 8080 + # bindAddress: "" # Network interface to bind; empty = all interfaces (0.0.0.0) + # maxRequestBodyBytes: -1 # Maximum request body in bytes; -1 = unlimited + # tls: # Omit entire section to use plain HTTP + # keystoreFile: keystore.p12 # Path to Java keystore (relative to config or absolute) + # keystorePassword: ${#env('KEYSTORE_PASSWORD')} # Keystore password + # keyPassword: ${#env('KEY_PASSWORD')} # Private-key password; defaults to keystorePassword + # keystoreType: PKCS12 # Keystore type (PKCS12 or JKS; default: PKCS12) + imports: - actions/http-fod.yaml -# THREAD MODEL -# The HTTP MCP server uses three independent thread pools: +# Optional jobs settings (shown with default values): +# jobs: # -# workThreads -- handles MCP tool call requests. Each simultaneous tool call -# from any user occupies one work thread for its full duration. -# Size this to: expected_concurrent_users * max_parallel_tool_calls_per_user. +# # THREAD POOLS +# # workThreads handles MCP tool call requests. Each simultaneous tool call from any user +# # occupies one work thread for its full duration. Size to: +# # expected_concurrent_users * max_parallel_tool_calls_per_user +# workThreads: 20 # -# progressThreads -- polls progress for long-running jobs at regular intervals. -# One thread is consumed per active long-running job during each poll cycle. -# 4 threads is sufficient for most deployments. +# # progressThreads polls progress for long-running jobs at regular intervals. +# # One thread is consumed per active long-running job during each poll cycle. +# # 4 threads is sufficient for most deployments. +# progressThreads: 4 # -# asyncBgThreads -- runs background async streaming jobs (e.g. run.fcli steps with streaming -# output). Each active async streaming job occupies one background thread. -# Increase if actions make heavy use of async streaming. - -# Optional settings (shown with default values): -# workThreads: 20 # Number of threads for handling MCP tool call requests -# progressThreads: 4 # Number of threads for streaming progress updates -# asyncBgThreads: 2 # Number of background threads for async job processing -# jobSafeReturn: 25s # Max time to wait for a job result before returning a job ID -# progressInterval: 5s # Interval between progress update checks -# isolationScopeTtl: 4h # Idle timeout for per-user session/variable state; state is +# # asyncBgThreads runs background async streaming jobs (e.g. run.fcli steps with +# # streaming output). Each active async streaming job occupies one background thread. +# # Increase if actions make heavy use of async streaming. +# asyncBgThreads: 2 +# +# # JOB TIMING / LIFECYCLE +# safeReturn: 25s # Max time to wait for a job result before returning a job ID +# progressInterval: 5s # Interval between progress update checks +# isolationScopeTtl: 4h # Idle timeout for per-user session/variable state; state is # # discarded and variables are deleted after this period of inactivity fod: diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml index 0bbf9691e65..bffd03c7bf7 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml @@ -1,29 +1,39 @@ -port: 8080 +server: + port: 8080 + # bindAddress: "" # Network interface to bind; empty = all interfaces (0.0.0.0) + # maxRequestBodyBytes: -1 # Maximum request body in bytes; -1 = unlimited + # tls: # Omit entire section to use plain HTTP + # keystoreFile: keystore.p12 # Path to Java keystore (relative to config or absolute) + # keystorePassword: ${#env('KEYSTORE_PASSWORD')} # Keystore password + # keyPassword: ${#env('KEY_PASSWORD')} # Private-key password; defaults to keystorePassword + # keystoreType: PKCS12 # Keystore type (PKCS12 or JKS; default: PKCS12) + imports: - actions/http-ssc.yaml -# THREAD MODEL -# The HTTP MCP server uses three independent thread pools: +# Optional jobs settings (shown with default values): +# jobs: # -# workThreads -- handles MCP tool call requests. Each simultaneous tool call -# from any user occupies one work thread for its full duration. -# Size this to: expected_concurrent_users * max_parallel_tool_calls_per_user. +# # THREAD POOLS +# # workThreads handles MCP tool call requests. Each simultaneous tool call from any user +# # occupies one work thread for its full duration. Size to: +# # expected_concurrent_users * max_parallel_tool_calls_per_user +# workThreads: 20 # -# progressThreads -- polls progress for long-running jobs at regular intervals. -# One thread is consumed per active long-running job during each poll cycle. -# 4 threads is sufficient for most deployments. +# # progressThreads polls progress for long-running jobs at regular intervals. +# # One thread is consumed per active long-running job during each poll cycle. +# # 4 threads is sufficient for most deployments. +# progressThreads: 4 # -# asyncBgThreads -- runs background async streaming jobs (e.g. run.fcli steps with streaming -# output). Each active async streaming job occupies one background thread. -# Increase if actions make heavy use of async streaming. - -# Optional settings (shown with default values): -# workThreads: 20 # Number of threads for handling MCP tool call requests -# progressThreads: 4 # Number of threads for streaming progress updates -# asyncBgThreads: 2 # Number of background threads for async job processing -# jobSafeReturn: 25s # Max time to wait for a job result before returning a job ID -# progressInterval: 5s # Interval between progress update checks -# isolationScopeTtl: 4h # Idle timeout for per-user session/variable state; state is +# # asyncBgThreads runs background async streaming jobs (e.g. run.fcli steps with +# # streaming output). Each active async streaming job occupies one background thread. +# # Increase if actions make heavy use of async streaming. +# asyncBgThreads: 2 +# +# # JOB TIMING / LIFECYCLE +# safeReturn: 25s # Max time to wait for a job result before returning a job ID +# progressInterval: 5s # Interval between progress update checks +# isolationScopeTtl: 4h # Idle timeout for per-user session/variable state; state is # # discarded and variables are deleted after this period of inactivity ssc: diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java index df6c05cb19e..7419bea8568 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java @@ -49,7 +49,7 @@ void loadResolvesRelativeImportsAndTemplateExpressionsForSscConfig() throws Exce MCPServerHttpConfig config = MCPServerHttpConfigLoader.load(configFile); assertEquals(MCPServerHttpConfig.Product.ssc, config.getProduct()); - assertEquals(8080, config.getPort()); + assertEquals(8080, config.getServer().getPort()); assertEquals("https://ssc.example.com", config.getSsc().getUrl()); assertEquals("secret-token", config.getSsc().getScSastClientAuthToken()); assertEquals(importFile.toAbsolutePath().normalize(), config.getResolvedImportPaths().get(0)); From 2a3bc93b8b708594517871bf3fb2dcc5f97fae24 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 18 May 2026 12:25:42 +0200 Subject: [PATCH 31/55] chore: Improve thread safety; create new descriptor instead of updating old one --- ...CPServerHttpSessionDescriptorResolver.java | 28 ++++++++++--------- .../session/helper/FoDSessionDescriptor.java | 10 +++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 5f2a235f85d..7ca1f9d6718 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -188,32 +188,35 @@ private FcliIsolationScope createIsolationScope(String authScopeKey, McpTranspor /** * Validates and if necessary refreshes the session token for the given entry. * Synchronized on the entry to prevent concurrent refreshes for the same user. + * A new descriptor instance is published into the scope rather than mutating the existing one, + * keeping the descriptor classes free of concurrency concerns. */ private void validateAndRefreshSession(IsolationScopeEntry entry, McpTransportContext transportContext) { synchronized (entry) { - var descriptors = entry.scope.getTransientSessionDescriptors(); - if ( descriptors.isEmpty() ) { - return; - } - var descriptor = descriptors.values().iterator().next(); - if ( descriptor instanceof FoDSessionDescriptor fodDescriptor ) { - refreshFoDTokenIfExpired(fodDescriptor, transportContext); - } else if ( descriptor instanceof SSCAndScanCentralSessionDescriptor sscDescriptor ) { - validateSscToken(sscDescriptor); + for ( var descriptor : entry.scope.getTransientSessionDescriptors().values() ) { + if ( descriptor instanceof FoDSessionDescriptor fodDescriptor ) { + // OAuth tokens expire; replace the descriptor with one carrying a fresh token if needed + entry.scope.setTransientSessionDescriptor(refreshFoDTokenIfExpired(fodDescriptor, transportContext)); + } else if ( descriptor instanceof SSCAndScanCentralSessionDescriptor sscDescriptor ) { + // Client always provides its own token; just validate it — no descriptor replacement needed + // If we every add support for user/pwd-based SSC auth, we may need to add token refresh + // logic here as well + validateSscToken(sscDescriptor); + } } } } - private void refreshFoDTokenIfExpired(FoDSessionDescriptor descriptor, McpTransportContext transportContext) { + private FoDSessionDescriptor refreshFoDTokenIfExpired(FoDSessionDescriptor descriptor, McpTransportContext transportContext) { if ( descriptor.hasActiveCachedTokenResponse() ) { - return; + return descriptor; } var auth = parseAuthHeader(transportContext); var fodConfig = config.getFod(); var urlConfig = UrlConfig.builderFromConnectionConfig(fodConfig) .url(FoDProductHelper.INSTANCE.getApiUrl(fodConfig.getUrl())) .build(); - descriptor.setCachedTokenResponse(createFoDTokenResponse(auth, urlConfig)); + return descriptor.withCachedTokenResponse(createFoDTokenResponse(auth, urlConfig)); } private void validateSscToken(SSCAndScanCentralSessionDescriptor descriptor) { @@ -225,7 +228,6 @@ private void validateSscToken(SSCAndScanCentralSessionDescriptor descriptor) { if ( !status.valid() ) { throw new FcliSimpleException("SSC session token is invalid or has been revoked; please provide a valid token in the %s header", HEADER_AUTH_SSC); } - descriptor.setSscTokenData(status.tokenData()); } private void deleteDirQuietly(Path absolutePath) { diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDSessionDescriptor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDSessionDescriptor.java index 56ce5030a15..011dde4d877 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDSessionDescriptor.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDSessionDescriptor.java @@ -35,6 +35,16 @@ public FoDSessionDescriptor(IUrlConfig urlConfig, FoDTokenCreateResponse tokenRe super(urlConfig); this.cachedTokenResponse = tokenResponse; } + + /** + * Returns a copy of this descriptor with the given token response, preserving all other fields + * including the URL configuration and any fields that may be added in the future. + */ + public FoDSessionDescriptor withCachedTokenResponse(FoDTokenCreateResponse newTokenResponse) { + var copy = new FoDSessionDescriptor(getUrlConfig(), newTokenResponse); + copy.setCreatedDate(getCreatedDate()); + return copy; + } @JsonIgnore public final boolean hasActiveCachedTokenResponse() { From 5f4fd26a218f46a2f02bb0cfa6103090b9edf4b1 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 18 May 2026 16:35:09 +0200 Subject: [PATCH 32/55] chore: Per-request log mask context --- .../mcp/cli/cmd/AgentMCPStartHttpCommand.java | 33 ++- .../http/MCPServerHttpAuthHeaderParser.java | 211 ++++++++++++++++ ...CPServerHttpSessionDescriptorResolver.java | 239 ++---------------- .../mcp/helper/http/ParsedAuthorization.java | 61 +++++ ...rverHttpSessionDescriptorResolverTest.java | 69 ++--- .../util/FortifyCLIDynamicInitializer.java | 4 +- .../common/cli/util/FcliExecutionContext.java | 11 +- .../cli/util/FcliExecutionContextHolder.java | 10 + .../cli/common/log/LogMaskContext.java | 121 +++++++++ .../fortify/cli/common/log/LogMaskHelper.java | 185 +++++++------- .../fortify/cli/common/log/LogMaskSource.java | 2 +- .../session/helper/AbstractSessionHelper.java | 26 +- 12 files changed, 607 insertions(+), 365 deletions(-) create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpAuthHeaderParser.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/ParsedAuthorization.java create mode 100644 fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskContext.java diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java index e1b095e58f8..730490f11f1 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java @@ -24,17 +24,21 @@ import com.fortify.cli.agent.mcp.helper.MCPImportedActionMcpSpecsFactory; import com.fortify.cli.agent.mcp.helper.MCPJobManager; import com.fortify.cli.agent.mcp.helper.http.JdkHttpServerMcpStatelessTransport; +import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpAuthHeaderParser; import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfigLoader; import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpSessionDescriptorResolver; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.cli.util.FcliIsolationScope; import com.fortify.cli.common.cli.util.IFcliExecutionContextManager; import com.fortify.cli.common.cli.util.StdioHelper; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.log.LogMaskContext; import com.fortify.cli.common.mcp.MCPExclude; +import com.fortify.cli.common.session.helper.AbstractSessionHelper; import com.fortify.cli.common.util.DateTimePeriodHelper; import com.fortify.cli.common.util.DateTimePeriodHelper.Period; import com.fortify.cli.common.util.FcliBuildProperties; @@ -84,6 +88,7 @@ public Integer call() throws Exception { asyncJobManager ); + var authHeaderParser = new MCPServerHttpAuthHeaderParser(config); var sessionDescriptorResolver = new MCPServerHttpSessionDescriptorResolver(config); var scopeCleanupScheduler = Executors.newSingleThreadScheduledExecutor( r -> new Thread(r, "mcp-http-scope-cleanup")); @@ -96,20 +101,20 @@ public Integer call() throws Exception { var importedSpecs = importSpecsFactory.create(importPath); importedSpecs.tools().forEach(tool -> toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() .tool(tool.tool()) - .callHandler((ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, + .callHandler((ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, authHeaderParser, () -> tool.callHandler().apply(ctx, request))) .build())); importedSpecs.resourceTemplates().forEach(resourceTemplate -> resourceTemplateSpecs.add( new McpStatelessServerFeatures.SyncResourceTemplateSpecification( resourceTemplate.resourceTemplate(), - (ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, + (ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, authHeaderParser, () -> resourceTemplate.readHandler().apply(ctx, request)) ))); } var jobToolSpec = jobManager.getJobToolSpecification(); toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() .tool(jobToolSpec.tool()) - .callHandler((ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, + .callHandler((ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, authHeaderParser, () -> jobToolSpec.callHandler().apply(null, request))) .build()); @@ -150,11 +155,27 @@ public Integer call() throws Exception { private T withRequestExecutionContext(McpTransportContext transportContext, MCPServerHttpSessionDescriptorResolver sessionDescriptorResolver, + MCPServerHttpAuthHeaderParser authHeaderParser, Supplier supplier) { - var isolationScope = sessionDescriptorResolver.getOrCreateIsolationScope(transportContext); - try (var frame = FcliExecutionContextHolder.push(new FcliExecutionContext(isolationScope, new FcliActionState()))) { - return supplier.get(); + var requestLogMaskCtx = new LogMaskContext(); + // Temp frame: push an empty scope so activeContext() = requestLogMaskCtx. + // This ensures X-AUTH credentials and any values discovered by global patterns + // (e.g. FoD OAuth token from the token-fetch response) are captured per-request. + try (var tempFrame = FcliExecutionContextHolder.push( + new FcliExecutionContext(new FcliIsolationScope(), new FcliActionState(), requestLogMaskCtx))) { + var auth = authHeaderParser.parseAndRegister(transportContext); + var isolationScope = sessionDescriptorResolver.getOrCreateIsolationScope(auth); + // Real frame: same requestLogMaskCtx, real isolation scope. + try (var frame = FcliExecutionContextHolder.push( + new FcliExecutionContext(isolationScope, new FcliActionState(), requestLogMaskCtx))) { + // Register current tokens from transient session descriptor so they are + // masked in this request's log output (mirrors AbstractSessionHelper.get() for + // disk-backed sessions; needed here because transient sessions bypass that path). + isolationScope.getTransientSessionDescriptors().values() + .forEach(AbstractSessionHelper::registerLogMasks); + return supplier.get(); + } } } diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpAuthHeaderParser.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpAuthHeaderParser.java new file mode 100644 index 00000000000..0f1109eeb74 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpAuthHeaderParser.java @@ -0,0 +1,211 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.mcp.helper.http; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; + +import io.modelcontextprotocol.common.McpTransportContext; +import lombok.RequiredArgsConstructor; + +/** + * Parses the X-AUTH-SSC or X-AUTH-FOD HTTP header from an incoming MCP request into a + * {@link ParsedAuthorization} record. + * + *

    The header value is a semicolon-separated list of {@code key=value} pairs. + * Backslash-escaping is supported for {@code \}, {@code ;} and {@code =}.

    + */ +@RequiredArgsConstructor +public final class MCPServerHttpAuthHeaderParser { + private final MCPServerHttpConfig config; + + public ParsedAuthorization parse(McpTransportContext transportContext) { + var product = config.getProduct(); + var headerName = getAuthHeaderName(product); + var headerValue = getRequiredHeader(transportContext, headerName); + var keyValues = parseAuthHeaderKeyValues(headerValue, headerName); + return switch ( product ) { + case ssc -> parseSscAuthorization(keyValues); + case fod -> parseFoDAuthorization(keyValues); + }; + } + + /** + * Parses the auth header and immediately registers all credential values for log masking. + * Must be called after a {@link com.fortify.cli.common.cli.util.FcliExecutionContext} has + * been pushed so that the credentials are registered into the per-request + * {@link com.fortify.cli.common.log.LogMaskContext}. + */ + public ParsedAuthorization parseAndRegister(McpTransportContext transportContext) { + var auth = parse(transportContext); + auth.registerCredentials(); + return auth; + } + + private String getAuthHeaderName(MCPServerHttpConfig.Product product) { + return switch ( product ) { + case ssc -> MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC; + case fod -> MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_FOD; + }; + } + + private ParsedAuthorization parseSscAuthorization(Map keyValues) { + var token = keyValues.get(MCPServerHttpSessionDescriptorResolver.SSC_TOKEN_KEY); + if ( StringUtils.isBlank(token) ) { + throw new FcliSimpleException("%s header requires key '%s'", + MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, + MCPServerHttpSessionDescriptorResolver.SSC_TOKEN_KEY); + } + return new ParsedAuthorization( + MCPServerHttpConfig.Product.ssc, + token, + keyValues.get(MCPServerHttpSessionDescriptorResolver.SSC_SC_SAST_CLIENT_AUTH_TOKEN_KEY), + null, null, null, null, null); + } + + private ParsedAuthorization parseFoDAuthorization(Map keyValues) { + return new ParsedAuthorization( + MCPServerHttpConfig.Product.fod, + null, + null, + keyValues.get(MCPServerHttpSessionDescriptorResolver.FOD_CLIENT_ID_KEY), + keyValues.get(MCPServerHttpSessionDescriptorResolver.FOD_CLIENT_SECRET_KEY), + keyValues.get(MCPServerHttpSessionDescriptorResolver.FOD_TENANT_KEY), + keyValues.get(MCPServerHttpSessionDescriptorResolver.FOD_USER_KEY), + keyValues.get(MCPServerHttpSessionDescriptorResolver.FOD_PAT_KEY)); + } + + private Map parseAuthHeaderKeyValues(String valuePart, String headerName) { + var result = new LinkedHashMap(); + for ( var segment : splitEscapedSegments(valuePart, headerName) ) { + var trimmedSegment = StringUtils.trimToNull(segment); + if ( trimmedSegment == null ) { + continue; + } + var separatorIndex = findUnescapedSeparator(trimmedSegment, '='); + if ( separatorIndex <= 0 || separatorIndex == trimmedSegment.length() - 1 ) { + throw new FcliSimpleException("Invalid %s header segment '%s'; expected key=value", headerName, trimmedSegment); + } + var key = StringUtils.trimToNull(unescapeHeaderValue(trimmedSegment.substring(0, separatorIndex), headerName)); + var value = StringUtils.trimToNull(unescapeHeaderValue(trimmedSegment.substring(separatorIndex + 1), headerName)); + if ( key == null || value == null ) { + throw new FcliSimpleException("Invalid %s header segment '%s'; expected key=value", headerName, trimmedSegment); + } + var normalizedKey = key.toLowerCase(Locale.ROOT); + if ( result.containsKey(normalizedKey) ) { + throw new FcliSimpleException("Duplicate %s header key: %s", headerName, key); + } + result.put(normalizedKey, value); + } + if ( result.isEmpty() ) { + throw new FcliSimpleException("%s header doesn't contain any key/value entries", headerName); + } + return result; + } + + private List splitEscapedSegments(String valuePart, String headerName) { + var result = new ArrayList(); + var current = new StringBuilder(); + var escaping = false; + for ( var i = 0; i < valuePart.length(); i++ ) { + var c = valuePart.charAt(i); + if ( escaping ) { + validateEscapeCharacter(c, headerName); + current.append('\\').append(c); + escaping = false; + } else if ( c == '\\' ) { + escaping = true; + } else if ( c == ';' ) { + result.add(current.toString()); + current.setLength(0); + } else { + current.append(c); + } + } + if ( escaping ) { + throw new FcliSimpleException("Invalid %s header value; trailing escape character", headerName); + } + result.add(current.toString()); + return result; + } + + private int findUnescapedSeparator(String value, char separator) { + var escaping = false; + for ( var i = 0; i < value.length(); i++ ) { + var c = value.charAt(i); + if ( escaping ) { + escaping = false; + } else if ( c == '\\' ) { + escaping = true; + } else if ( c == separator ) { + return i; + } + } + return -1; + } + + private String unescapeHeaderValue(String value, String headerName) { + var result = new StringBuilder(); + var escaping = false; + for ( var i = 0; i < value.length(); i++ ) { + var c = value.charAt(i); + if ( escaping ) { + validateEscapeCharacter(c, headerName); + result.append(c); + escaping = false; + } else if ( c == '\\' ) { + escaping = true; + } else { + result.append(c); + } + } + if ( escaping ) { + throw new FcliSimpleException("Invalid %s header value; trailing escape character", headerName); + } + return result.toString(); + } + + private void validateEscapeCharacter(char c, String headerName) { + if ( c != '\\' && c != ';' && c != '=' ) { + throw new FcliSimpleException("Invalid %s header escape sequence '\\%s'; supported escapes are \\\\, \\; and \\=", headerName, c); + } + } + + @SuppressWarnings("unchecked") + private String getOptionalHeader(McpTransportContext transportContext, String headerName) { + var headers = (Map>) transportContext.get("headers"); + if ( headers == null || headers.isEmpty() ) { return null; } + return headers.entrySet().stream() + .filter(entry -> headerName.equalsIgnoreCase(entry.getKey())) + .map(Map.Entry::getValue) + .filter(values -> values != null && !values.isEmpty()) + .map(values -> values.get(0)) + .map(StringUtils::trimToNull) + .findFirst().orElse(null); + } + + private String getRequiredHeader(McpTransportContext transportContext, String headerName) { + var value = getOptionalHeader(transportContext, headerName); + if ( StringUtils.isBlank(value) ) { + throw new FcliSimpleException("Missing required HTTP header: %s", headerName); + } + return value; + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 7ca1f9d6718..6805e8f3fd2 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -17,13 +17,8 @@ import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; import java.util.HashSet; import java.util.HexFormat; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; @@ -54,7 +49,6 @@ import com.fortify.cli.ssc._common.session.helper.SSCSessionValidationHelper; import com.fortify.cli.ssc.access_control.helper.SSCTokenGetOrCreateResponse.SSCTokenData; -import io.modelcontextprotocol.common.McpTransportContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -64,13 +58,14 @@ public final class MCPServerHttpSessionDescriptorResolver { public static final String HEADER_AUTH_SSC = "X-AUTH-SSC"; public static final String HEADER_AUTH_FOD = "X-AUTH-FOD"; - private static final String SSC_TOKEN_KEY = "token"; - private static final String SSC_SC_SAST_CLIENT_AUTH_TOKEN_KEY = "sc-sast-token"; - private static final String FOD_TENANT_KEY = "tenant"; - private static final String FOD_USER_KEY = "user"; - private static final String FOD_PAT_KEY = "pat"; - private static final String FOD_CLIENT_ID_KEY = "client-id"; - private static final String FOD_CLIENT_SECRET_KEY = "client-secret"; + // Package-visible so MCPServerHttpAuthHeaderParser can reference them as constants + static final String SSC_TOKEN_KEY = "token"; + static final String SSC_SC_SAST_CLIENT_AUTH_TOKEN_KEY = "sc-sast-token"; + static final String FOD_TENANT_KEY = "tenant"; + static final String FOD_USER_KEY = "user"; + static final String FOD_PAT_KEY = "pat"; + static final String FOD_CLIENT_ID_KEY = "client-id"; + static final String FOD_CLIENT_SECRET_KEY = "client-secret"; private static final String[] DEFAULT_FOD_SCOPES = new String[] {"api-tenant"}; @@ -121,23 +116,19 @@ public FcliExecutionContextHolder.ContextFrame getOrCreateFunctionFrame(String a * {@link com.fortify.cli.common.exception.FcliSimpleException} is thrown if the token * is invalid or has been revoked. */ - public FcliIsolationScope getOrCreateIsolationScope(McpTransportContext transportContext) { - var authScopeKey = createAuthCacheKey(transportContext); + public FcliIsolationScope getOrCreateIsolationScope(ParsedAuthorization auth) { + var authScopeKey = createAuthCacheKey(auth); var entry = isolationScopeCache.get(authScopeKey); if ( entry == null ) { - var newEntry = new IsolationScopeEntry(createIsolationScope(authScopeKey, transportContext)); + var newEntry = new IsolationScopeEntry(createIsolationScope(authScopeKey, auth)); var existing = isolationScopeCache.putIfAbsent(authScopeKey, newEntry); entry = existing != null ? existing : newEntry; } entry.updateLastAccess(); - validateAndRefreshSession(entry, transportContext); + validateAndRefreshSession(entry, auth); return entry.scope; } - public String getAuthScopeKey(McpTransportContext transportContext) { - return createAuthCacheKey(transportContext); - } - /** * Schedules periodic eviction of isolation scopes that have not been accessed within * {@code ttlMillis}. The cleanup interval is {@code max(1 minute, ttlMillis / 4)}. @@ -177,10 +168,10 @@ private FcliIsolationScope getExistingIsolationScope(String authScopeKey) { return entry.scope; } - private FcliIsolationScope createIsolationScope(String authScopeKey, McpTransportContext transportContext) { + private FcliIsolationScope createIsolationScope(String authScopeKey, ParsedAuthorization auth) { var result = new FcliIsolationScope(); result.setMcpRequestAuthScopeKey(authScopeKey); - result.setTransientSessionDescriptor(createSessionDescriptor(transportContext)); + result.setTransientSessionDescriptor(createSessionDescriptor(auth)); result.setScopedVarsPath(mcpScopedVarsRootPath.resolve(authScopeKey.replace("|", "_"))); return result; } @@ -191,12 +182,12 @@ private FcliIsolationScope createIsolationScope(String authScopeKey, McpTranspor * A new descriptor instance is published into the scope rather than mutating the existing one, * keeping the descriptor classes free of concurrency concerns. */ - private void validateAndRefreshSession(IsolationScopeEntry entry, McpTransportContext transportContext) { + private void validateAndRefreshSession(IsolationScopeEntry entry, ParsedAuthorization auth) { synchronized (entry) { for ( var descriptor : entry.scope.getTransientSessionDescriptors().values() ) { if ( descriptor instanceof FoDSessionDescriptor fodDescriptor ) { // OAuth tokens expire; replace the descriptor with one carrying a fresh token if needed - entry.scope.setTransientSessionDescriptor(refreshFoDTokenIfExpired(fodDescriptor, transportContext)); + entry.scope.setTransientSessionDescriptor(refreshFoDTokenIfExpired(fodDescriptor, auth)); } else if ( descriptor instanceof SSCAndScanCentralSessionDescriptor sscDescriptor ) { // Client always provides its own token; just validate it — no descriptor replacement needed // If we every add support for user/pwd-based SSC auth, we may need to add token refresh @@ -207,11 +198,10 @@ private void validateAndRefreshSession(IsolationScopeEntry entry, McpTransportCo } } - private FoDSessionDescriptor refreshFoDTokenIfExpired(FoDSessionDescriptor descriptor, McpTransportContext transportContext) { + private FoDSessionDescriptor refreshFoDTokenIfExpired(FoDSessionDescriptor descriptor, ParsedAuthorization auth) { if ( descriptor.hasActiveCachedTokenResponse() ) { return descriptor; } - var auth = parseAuthHeader(transportContext); var fodConfig = config.getFod(); var urlConfig = UrlConfig.builderFromConnectionConfig(fodConfig) .url(FoDProductHelper.INSTANCE.getApiUrl(fodConfig.getUrl())) @@ -241,9 +231,8 @@ private void deleteDirQuietly(Path absolutePath) { } } - String createAuthCacheKey(McpTransportContext transportContext) { - var auth = parseAuthHeader(transportContext); - return switch (auth.product()) { + String createAuthCacheKey(ParsedAuthorization auth) { + return switch ( auth.product() ) { case ssc -> createSscAuthCacheKey(auth); case fod -> createFoDAuthCacheKey(auth); }; @@ -309,9 +298,8 @@ private MessageDigest getDigest() { } } - private ISessionDescriptor createSessionDescriptor(McpTransportContext transportContext) { - var auth = parseAuthHeader(transportContext); - return switch (auth.product()) { + private ISessionDescriptor createSessionDescriptor(ParsedAuthorization auth) { + return switch ( auth.product() ) { case ssc -> createSscSessionDescriptor(auth); case fod -> createFoDSessionDescriptor(auth); }; @@ -366,189 +354,6 @@ private FoDTokenCreateResponse createFoDTokenResponse(ParsedAuthorization auth, ); } - @SuppressWarnings("unchecked") - private String getOptionalHeader(McpTransportContext transportContext, String headerName) { - var headers = (Map>)transportContext.get("headers"); - if ( headers == null || headers.isEmpty() ) { - return null; - } - return headers.entrySet().stream() - .filter(entry -> headerName.equalsIgnoreCase(entry.getKey())) - .map(Map.Entry::getValue) - .filter(values -> values != null && !values.isEmpty()) - .map(values -> values.get(0)) - .map(StringUtils::trimToNull) - .findFirst().orElse(null); - } - - private String getRequiredHeader(McpTransportContext transportContext, String headerName) { - var value = getOptionalHeader(transportContext, headerName); - if ( StringUtils.isBlank(value) ) { - throw new FcliSimpleException("Missing required HTTP header: %s", headerName); - } - return value; - } - - private ParsedAuthorization parseAuthHeader(McpTransportContext transportContext) { - var product = config.getProduct(); - var headerName = getAuthHeaderName(product); - var headerValue = getRequiredHeader(transportContext, headerName); - var keyValues = parseAuthHeaderKeyValues(headerValue, headerName); - return switch (product) { - case ssc -> parseSscAuthorization(keyValues); - case fod -> parseFoDAuthorization(keyValues); - }; - } - - private String getAuthHeaderName(MCPServerHttpConfig.Product product) { - return switch (product) { - case ssc -> HEADER_AUTH_SSC; - case fod -> HEADER_AUTH_FOD; - }; - } - - private Map parseAuthHeaderKeyValues(String valuePart, String headerName) { - var result = new LinkedHashMap(); - for ( var segment : splitEscapedSegments(valuePart, headerName) ) { - var trimmedSegment = StringUtils.trimToNull(segment); - if ( trimmedSegment == null ) { - continue; - } - var separatorIndex = findUnescapedSeparator(trimmedSegment, '='); - if ( separatorIndex <= 0 || separatorIndex == trimmedSegment.length() - 1 ) { - throw new FcliSimpleException("Invalid %s header segment '%s'; expected key=value", headerName, trimmedSegment); - } - var key = StringUtils.trimToNull(unescapeHeaderValue(trimmedSegment.substring(0, separatorIndex), headerName)); - var value = StringUtils.trimToNull(unescapeHeaderValue(trimmedSegment.substring(separatorIndex + 1), headerName)); - if ( key == null || value == null ) { - throw new FcliSimpleException("Invalid %s header segment '%s'; expected key=value", headerName, trimmedSegment); - } - var normalizedKey = key.toLowerCase(Locale.ROOT); - if ( result.containsKey(normalizedKey) ) { - throw new FcliSimpleException("Duplicate %s header key: %s", headerName, key); - } - result.put(normalizedKey, value); - } - if ( result.isEmpty() ) { - throw new FcliSimpleException("%s header doesn't contain any key/value entries", headerName); - } - return result; - } - - private List splitEscapedSegments(String valuePart, String headerName) { - var result = new ArrayList(); - var current = new StringBuilder(); - var escaping = false; - for ( var i = 0; i < valuePart.length(); i++ ) { - var c = valuePart.charAt(i); - if ( escaping ) { - validateEscapeCharacter(c, headerName); - current.append('\\').append(c); - escaping = false; - } else if ( c == '\\' ) { - escaping = true; - } else if ( c == ';' ) { - result.add(current.toString()); - current.setLength(0); - } else { - current.append(c); - } - } - if ( escaping ) { - throw new FcliSimpleException("Invalid %s header value; trailing escape character", headerName); - } - result.add(current.toString()); - return result; - } - - private int findUnescapedSeparator(String value, char separator) { - var escaping = false; - for ( var i = 0; i < value.length(); i++ ) { - var c = value.charAt(i); - if ( escaping ) { - escaping = false; - } else if ( c == '\\' ) { - escaping = true; - } else if ( c == separator ) { - return i; - } - } - return -1; - } - - private String unescapeHeaderValue(String value, String headerName) { - var result = new StringBuilder(); - var escaping = false; - for ( var i = 0; i < value.length(); i++ ) { - var c = value.charAt(i); - if ( escaping ) { - validateEscapeCharacter(c, headerName); - result.append(c); - escaping = false; - } else if ( c == '\\' ) { - escaping = true; - } else { - result.append(c); - } - } - if ( escaping ) { - throw new FcliSimpleException("Invalid %s header value; trailing escape character", headerName); - } - return result.toString(); - } - - private void validateEscapeCharacter(char c, String headerName) { - if ( c != '\\' && c != ';' && c != '=' ) { - throw new FcliSimpleException("Invalid %s header escape sequence '\\%s'; supported escapes are \\\\, \\; and \\=", headerName, c); - } - } - - private ParsedAuthorization parseSscAuthorization(Map keyValues) { - var token = keyValues.get(SSC_TOKEN_KEY); - if ( StringUtils.isBlank(token) ) { - throw new FcliSimpleException("%s header requires key '%s'", HEADER_AUTH_SSC, SSC_TOKEN_KEY); - } - return new ParsedAuthorization( - MCPServerHttpConfig.Product.ssc, - token, - keyValues.get(SSC_SC_SAST_CLIENT_AUTH_TOKEN_KEY), - null, - null, - null, - null, - null - ); - } - - private ParsedAuthorization parseFoDAuthorization(Map keyValues) { - var clientId = keyValues.get(FOD_CLIENT_ID_KEY); - var clientSecret = keyValues.get(FOD_CLIENT_SECRET_KEY); - var tenant = keyValues.get(FOD_TENANT_KEY); - var user = keyValues.get(FOD_USER_KEY); - var pat = keyValues.get(FOD_PAT_KEY); - return new ParsedAuthorization( - MCPServerHttpConfig.Product.fod, - null, - null, - clientId, - clientSecret, - tenant, - user, - pat - ); - } - - private record ParsedAuthorization( - MCPServerHttpConfig.Product product, - String sscToken, - String scSastClientAuthToken, - String fodClientId, - String fodClientSecret, - String fodTenant, - String fodUser, - String fodPat - ) {} - private static final class HttpMcpFoDClientCredentials implements IFoDClientCredentials { private final String clientId; private final String clientSecret; @@ -658,4 +463,4 @@ public char[] getScSastClientAuthToken() { return scSastClientAuthToken; } } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/ParsedAuthorization.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/ParsedAuthorization.java new file mode 100644 index 00000000000..5145674fdec --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/ParsedAuthorization.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.mcp.helper.http; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.log.LogMaskHelper; +import com.fortify.cli.common.log.LogMaskSource; +import com.fortify.cli.common.log.LogSensitivityLevel; + +/** + * Parsed representation of an X-AUTH-SSC or X-AUTH-FOD header. + * + *

    Calling {@link #registerCredentials()} routes all credential values through + * {@link LogMaskHelper#registerValue} (which in turn routes to + * {@link com.fortify.cli.common.log.LogMaskContext#activeContext()}). This must be called + * while a pre-scope capture context is active so that credential masking is scoped to the + * current isolation scope rather than accumulated globally.

    + */ +record ParsedAuthorization( + MCPServerHttpConfig.Product product, + String sscToken, + String scSastClientAuthToken, + String fodClientId, + String fodClientSecret, + String fodTenant, + String fodUser, + String fodPat) { + + void registerCredentials() { + switch ( product ) { + case ssc -> { + register(LogSensitivityLevel.high, "SSC TOKEN", sscToken); + register(LogSensitivityLevel.high, "SSC SC-SAST TOKEN", scSastClientAuthToken); + } + case fod -> { + register(LogSensitivityLevel.medium, "FOD CLIENT ID", fodClientId); + register(LogSensitivityLevel.high, "FOD CLIENT SECRET", fodClientSecret); + register(LogSensitivityLevel.low, "FOD TENANT", fodTenant); + register(LogSensitivityLevel.medium, "USER", fodUser); + register(LogSensitivityLevel.high, "PASSWORD", fodPat); + } + } + } + + private static void register(LogSensitivityLevel sensitivity, String description, String value) { + if ( StringUtils.isNotBlank(value) ) { + LogMaskHelper.INSTANCE.registerValue(sensitivity, LogMaskSource.HTTP_AUTH_HEADER, description, value, ""); + } + } +} diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java index 57c49f2e281..7a6338965d6 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java +++ b/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java @@ -29,16 +29,14 @@ class MCPServerHttpSessionDescriptorResolverTest { @Test void createAuthCacheKeyHashesSscCredentials() { - var config = new MCPServerHttpConfig(); - var sscConfig = new MCPServerHttpConfig.SscConfig(); - sscConfig.setUrl("https://ssc.example.com"); - config.setSsc(sscConfig); + var config = sscConfig("https://ssc.example.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); var resolver = new MCPServerHttpSessionDescriptorResolver(config); - var cacheKey = resolver.createAuthCacheKey(transportContext(Map.of( + var cacheKey = resolver.createAuthCacheKey(parser.parse(transportContext(Map.of( MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, List.of("token=ssc-token;sc-sast-token=sast-token") - ))); + )))); assertTrue(cacheKey.startsWith("ssc|")); assertFalse(cacheKey.contains("ssc-token")); @@ -47,16 +45,14 @@ void createAuthCacheKeyHashesSscCredentials() { @Test void createAuthCacheKeyHashesFoDClientCredentials() { - var config = new MCPServerHttpConfig(); - var fodConfig = new MCPServerHttpConfig.FoDConfig(); - fodConfig.setUrl("https://api.ams.fortify.com"); - config.setFod(fodConfig); + var config = fodConfig("https://api.ams.fortify.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); var resolver = new MCPServerHttpSessionDescriptorResolver(config); - var cacheKey = resolver.createAuthCacheKey(transportContext(Map.of( + var cacheKey = resolver.createAuthCacheKey(parser.parse(transportContext(Map.of( MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_FOD, List.of("client-id=client-id;client-secret=client-secret") - ))); + )))); assertTrue(cacheKey.startsWith("fod-client|")); assertFalse(cacheKey.contains("client-id")); @@ -65,36 +61,32 @@ void createAuthCacheKeyHashesFoDClientCredentials() { @Test void createAuthCacheKeyRejectsMixedFoDAuthModes() { - var config = new MCPServerHttpConfig(); - var fodConfig = new MCPServerHttpConfig.FoDConfig(); - fodConfig.setUrl("https://api.ams.fortify.com"); - config.setFod(fodConfig); + var config = fodConfig("https://api.ams.fortify.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); var resolver = new MCPServerHttpSessionDescriptorResolver(config); - var exception = assertThrows(FcliSimpleException.class, () -> resolver.createAuthCacheKey(transportContext(Map.of( + var exception = assertThrows(FcliSimpleException.class, () -> resolver.createAuthCacheKey(parser.parse(transportContext(Map.of( MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_FOD, List.of("client-id=client-id;client-secret=client-secret;tenant=tenant;user=user;pat=pat") - )))); + ))))); assertTrue(exception.getMessage().contains("Specify either FoD client keys")); } @Test void createAuthCacheKeySupportsEscapedSemicolonBackslashAndEquals() { - var config = new MCPServerHttpConfig(); - var sscConfig = new MCPServerHttpConfig.SscConfig(); - sscConfig.setUrl("https://ssc.example.com"); - config.setSsc(sscConfig); + var config = sscConfig("https://ssc.example.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); var resolver = new MCPServerHttpSessionDescriptorResolver(config); - var cacheKeyA = resolver.createAuthCacheKey(transportContext(Map.of( + var cacheKeyA = resolver.createAuthCacheKey(parser.parse(transportContext(Map.of( MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, List.of("token=abc\\;def\\=ghi\\\\jkl;sc-sast-token=secondary") - ))); - var cacheKeyB = resolver.createAuthCacheKey(transportContext(Map.of( + )))); + var cacheKeyB = resolver.createAuthCacheKey(parser.parse(transportContext(Map.of( MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, List.of("token=abc;sc-sast-token=secondary") - ))); + )))); assertTrue(cacheKeyA.startsWith("ssc|")); assertFalse(cacheKeyA.contains("abc;def=ghi\\jkl")); @@ -103,13 +95,10 @@ void createAuthCacheKeySupportsEscapedSemicolonBackslashAndEquals() { @Test void createAuthCacheKeyRejectsInvalidEscapeSequence() { - var config = new MCPServerHttpConfig(); - var sscConfig = new MCPServerHttpConfig.SscConfig(); - sscConfig.setUrl("https://ssc.example.com"); - config.setSsc(sscConfig); - var resolver = new MCPServerHttpSessionDescriptorResolver(config); + var config = sscConfig("https://ssc.example.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); - var exception = assertThrows(FcliSimpleException.class, () -> resolver.createAuthCacheKey(transportContext(Map.of( + var exception = assertThrows(FcliSimpleException.class, () -> parser.parse(transportContext(Map.of( MCPServerHttpSessionDescriptorResolver.HEADER_AUTH_SSC, List.of("token=abc\\n") )))); @@ -120,4 +109,20 @@ void createAuthCacheKeyRejectsInvalidEscapeSequence() { private McpTransportContext transportContext(Map> headers) { return McpTransportContext.create(Map.of("headers", headers)); } + + private MCPServerHttpConfig sscConfig(String url) { + var config = new MCPServerHttpConfig(); + var sscConfig = new MCPServerHttpConfig.SscConfig(); + sscConfig.setUrl(url); + config.setSsc(sscConfig); + return config; + } + + private MCPServerHttpConfig fodConfig(String url) { + var config = new MCPServerHttpConfig(); + var fodConfig = new MCPServerHttpConfig.FoDConfig(); + fodConfig.setUrl(url); + config.setFod(fodConfig); + return config; + } } \ No newline at end of file diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/FortifyCLIDynamicInitializer.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/FortifyCLIDynamicInitializer.java index 106a340f028..b107bd2fa38 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/FortifyCLIDynamicInitializer.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/FortifyCLIDynamicInitializer.java @@ -101,11 +101,11 @@ public void initializeLogging(GenericOptionsArgGroup genericOptions) { private void registerDefaultLogMaskPatterns() { LogMaskHelper.INSTANCE - .registerPattern(LogSensitivityLevel.high, "Authorization: (?:[a-zA-Z]+ )?(.*?)(?:\\Q[\\r]\\E|\\Q[\\n]\\E)*\\\"?$", "", LogMessageType.HTTP_OUT) + .registerGlobalPattern(LogSensitivityLevel.high, "Authorization: (?:[a-zA-Z]+ )?(.*?)(?:\\Q[\\r]\\E|\\Q[\\n]\\E)*\\\"?$", "", LogMessageType.HTTP_OUT) // Match "token" or "access_token" fields, but exclude ScanCentral job token pattern {"token":"...","detailsMessage":null} // which contains a non-sensitive job identifier rather than an authentication token. // The negative lookahead checks that after the token value, we don't see the ScanCentral-specific trailing pattern. - .registerPattern(LogSensitivityLevel.high, "(?:\\\"token\\\"|\\\"access_token\\\"):\\s*\\\"(.*?)\\\"(?!,\\s*\\\"detailsMessage\\\":\\s*null\\s*\\})", "", LogMessageType.HTTP_IN); + .registerGlobalPattern(LogSensitivityLevel.high, "(?:\\\"token\\\"|\\\"access_token\\\"):\\s*\\\"(.*?)\\\"(?!,\\s*\\\"detailsMessage\\\":\\s*null\\s*\\})", "", LogMessageType.HTTP_IN); } @SuppressWarnings("unchecked") diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java index c4dc625b477..4625b5ee1f4 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java @@ -19,6 +19,7 @@ import java.util.Set; import com.fortify.cli.common.crypto.helper.EncryptionHelper; +import com.fortify.cli.common.log.LogMaskContext; import com.fortify.cli.common.rest.unirest.UnirestContext; import lombok.Getter; @@ -66,6 +67,7 @@ public final class FcliExecutionContext implements AutoCloseable { @Getter private final FcliIsolationScope isolationScope; @Getter private final FcliActionState actionState; + @Getter private final LogMaskContext logMaskContext; @Getter private final UnirestContext unirestContext = new UnirestContext(); // Encryption helper used for encrypt/decrypt in this execution. Default to global DEFAULT. private volatile EncryptionHelper encryptionHelper = EncryptionHelper.DEFAULT; @@ -73,12 +75,17 @@ public final class FcliExecutionContext implements AutoCloseable { private final Set ephemeralEncryptedFiles = java.util.concurrent.ConcurrentHashMap.newKeySet(); public FcliExecutionContext() { - this(new FcliIsolationScope(), new FcliActionState()); + this(new FcliIsolationScope(), new FcliActionState(), new LogMaskContext()); } public FcliExecutionContext(FcliIsolationScope isolationScope, FcliActionState actionState) { + this(isolationScope, actionState, new LogMaskContext()); + } + + public FcliExecutionContext(FcliIsolationScope isolationScope, FcliActionState actionState, LogMaskContext logMaskContext) { this.isolationScope = Objects.requireNonNull(isolationScope, "isolationScope"); this.actionState = Objects.requireNonNull(actionState, "actionState"); + this.logMaskContext = Objects.requireNonNull(logMaskContext, "logMaskContext"); } /** @@ -90,7 +97,7 @@ public FcliExecutionContext(FcliIsolationScope isolationScope, FcliActionState a * must not see or mutate the parent's {@code global.*} action variables.

    */ public FcliExecutionContext createChild() { - return new FcliExecutionContext(isolationScope, new FcliActionState()); + return new FcliExecutionContext(isolationScope, new FcliActionState(), logMaskContext); } /** diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java index 1df8882fca3..ea19ade8a59 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java @@ -114,6 +114,16 @@ public static ISessionDescriptor getTransientSessionDescriptor(String type) { public static String getMcpRequestAuthScopeKey() { return current().getIsolationScope().getMcpRequestAuthScopeKey(); } + + /** + * Return the current (top) execution context, or {@code null} if no context is pushed. + * Used by {@link com.fortify.cli.common.log.LogMaskContext#activeContext()} for + * per-request log masking; must not throw. + */ + public static FcliExecutionContext tryCurrentContext() { + var stack = HOLDER.get(); + return stack.isEmpty() ? null : stack.peek(); + } /** Return the current stack depth. Useful for logging/troubleshooting. */ public static int stackDepth() { return HOLDER.get().size(); } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskContext.java new file mode 100644 index 00000000000..872f54e9598 --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskContext.java @@ -0,0 +1,121 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.log; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; + +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.regex.MultiPatternReplacer; + +/** + * Holds a set of log-masking rules (values and patterns) scoped to a single execution context. + * + *

    Each {@link com.fortify.cli.common.cli.util.FcliExecutionContext} owns one instance. + * {@link #activeContext()} resolves the currently active context for the calling thread by + * inspecting the execution-context stack: if a context is pushed, its {@code logMaskContext} + * is returned; otherwise {@link #GLOBAL} is returned.

    + * + *

    {@link #GLOBAL} is used in plain CLI mode (where all option/session values are registered + * once into the global context) and as the baseline for all executions (globally-registered + * startup patterns such as HTTP response scanners are registered here).

    + * + *

    For server modes (MCP HTTP, MCP stdio, RPC), each request receives a fresh + * {@link com.fortify.cli.common.cli.util.FcliExecutionContext} with its own + * {@code LogMaskContext}, providing per-request isolation. Values discovered by + * global patterns during a request are registered into the per-request context via + * {@link #activeContext()}, so they are not retained beyond the request lifetime.

    + * + * @author Ruud Senden + */ +public final class LogMaskContext { + /** The single global context used in CLI mode and as baseline for all executions. */ + public static final LogMaskContext GLOBAL = new LogMaskContext(); + + private final Map patternReplacers = new ConcurrentHashMap<>(); + private final MultiPatternReplacer stdioReplacer = new MultiPatternReplacer(); + + // ------------------------------------------------------------------------- + // Static lifecycle + // ------------------------------------------------------------------------- + + /** + * @return the active context for the current thread: the {@link LogMaskContext} of the + * currently pushed {@link com.fortify.cli.common.cli.util.FcliExecutionContext}, + * or {@link #GLOBAL} if no context is present. + */ + public static LogMaskContext activeContext() { + var ctx = FcliExecutionContextHolder.tryCurrentContext(); + return ctx != null ? ctx.getLogMaskContext() : GLOBAL; + } + + // ------------------------------------------------------------------------- + // Instance methods (package-private; accessed by LogMaskHelper only) + // ------------------------------------------------------------------------- + + final LogMaskContext registerValue(LogSensitivityLevel sensitivityLevel, String valueToMask, String replacement, LogMessageType... logMessageTypes) { + if ( LogMaskHelper.INSTANCE.isMaskingNeeded(sensitivityLevel, valueToMask) ) { + var encodedValue = URLEncoder.encode(valueToMask, StandardCharsets.UTF_8); + for ( var logMessageType : getLogMessageTypesOrDefault(logMessageTypes) ) { + getOrCreateReplacer(logMessageType) + .registerValue(valueToMask, replacement) + .registerValue(encodedValue, replacement); + } + stdioReplacer + .registerValue(valueToMask, replacement) + .registerValue(encodedValue, replacement); + } + return this; + } + + final LogMaskContext registerStdioValue(LogSensitivityLevel sensitivityLevel, String valueToMask, String replacement) { + if ( LogMaskHelper.INSTANCE.isMaskingNeeded(sensitivityLevel, valueToMask) ) { + stdioReplacer.registerValue(valueToMask, replacement); + } + return this; + } + + final LogMaskContext registerPattern(LogSensitivityLevel sensitivityLevel, String patternString, String replacement, LogMessageType... logMessageTypes) { + if ( LogMaskHelper.INSTANCE.isMaskingNeededForSensitivityLevel(sensitivityLevel) ) { + for ( var logMessageType : getLogMessageTypesOrDefault(logMessageTypes) ) { + getOrCreateReplacer(logMessageType).registerPattern(patternString, replacement); + } + } + return this; + } + + final String mask(LogMessageType logMessageType, String msg, BiConsumer valueConsumer) { + var replacer = patternReplacers.get(logMessageType); + if ( replacer == null ) { return msg; } + return replacer.applyReplacements(msg, valueConsumer); + } + + final String maskStdio(String msg, BiConsumer valueConsumer) { + return stdioReplacer.applyReplacements(msg, valueConsumer); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private MultiPatternReplacer getOrCreateReplacer(LogMessageType logMessageType) { + return patternReplacers.computeIfAbsent(logMessageType, t -> new MultiPatternReplacer()); + } + + private LogMessageType[] getLogMessageTypesOrDefault(LogMessageType... logMessageTypes) { + return logMessageTypes != null && logMessageTypes.length != 0 ? logMessageTypes : LogMessageType.all(); + } +} diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskHelper.java index 04a8b19544a..413aa32bff2 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskHelper.java @@ -12,16 +12,12 @@ */ package com.fortify.cli.common.log; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import com.fortify.cli.common.exception.FcliBugException; -import com.fortify.cli.common.regex.MultiPatternReplacer; import com.fortify.cli.common.util.JavaHelper; import lombok.AccessLevel; @@ -32,21 +28,23 @@ * This class provides methods for registering values and patterns to be masked in the * fcli log file, and applying those masks to log messages. * + *

    Registration always targets {@link LogMaskContext#activeContext()}: the GLOBAL context + * in plain CLI mode, the pre-scope capture context during MCP HTTP request setup, or the + * current scope's context during tool execution. Masking is applied in two phases: + * {@link LogMaskContext#GLOBAL} first, then the active context (if different), so that + * globally-registered patterns catch values that are then re-registered scope-locally.

    + * * @author Ruud Senden */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class LogMaskHelper { /** Singleton instance of this class. */ public static final LogMaskHelper INSTANCE = new LogMaskHelper(); - + /** The log mask level to apply to logging entries. This is set by FortifyCLIDynamicInitializer based - * on the value of the generic fcli
    --log-mask
    option. */ + * on the value of the generic fcli
    --log-mask
    option. */ @Setter private LogMaskLevel logMaskLevel; - private final Map multiPatternReplacers = new ConcurrentHashMap<>(); - - /** Global pattern replacer for stdio masking (applies to all output regardless of message type) */ - private final MultiPatternReplacer stdioPatternReplacer = new MultiPatternReplacer(); - + /** * Register a value to be masked, based on the semantics as described in {@link MaskValue}. If * {@link MaskValue} is null, the given value will not be registered for masking. @@ -57,7 +55,7 @@ public final LogMaskHelper registerValue(MaskValue maskAnnotation, LogMaskSource } return this; } - + /** * Register a value to be masked, based on the semantics as described in {@link MaskValue}. If * {@link MaskValueDescriptor} is null, the given value will not be registered for masking. @@ -68,7 +66,7 @@ public final LogMaskHelper registerValue(MaskValueDescriptor maskDescriptor, Log } return this; } - + /** * Register a value to be masked, based on the same semantics as described in {@link MaskValue} but passing * each attribute of that annotation as a separate method argument. @@ -86,136 +84,125 @@ public final LogMaskHelper registerValue(LogSensitivityLevel sensitivityLevel, L } return registerValue(sensitivityLevel, valueString, String.format("", description.toUpperCase(), source)); } - + private final String valueAsString(Object value) { return JavaHelper.as(value, String.class).orElseGet( ()->JavaHelper.as(value, char[].class).map(ca->String.valueOf(ca)) .orElseThrow(()->new FcliBugException("MaskValue annotation can only be used on String or char[] fields, actual type: "+value.getClass().getSimpleName()))); } - + /** - * Register a value to be masked with the given {@link LogSensitivityLevel}, for the given log - * message type(s). If no log message types are provided, the mask will be applied to all log - * message types. Automatically also registers the value for stdio masking since any value - * sensitive enough to mask in logs should also be masked in console output. - * See {@link MultiPatternReplacer#registerValue(String, String)} for details. + * Register a value to be masked with the given {@link LogSensitivityLevel}, for the given log + * message type(s). Delegates to {@link LogMaskContext#activeContext()}. */ public final LogMaskHelper registerValue(LogSensitivityLevel sensitivityLevel, String valueToMask, String replacement, LogMessageType... logMessageTypes) { - if ( isMaskingNeeded(sensitivityLevel, valueToMask) ) { - var encodedValue = URLEncoder.encode(valueToMask, StandardCharsets.UTF_8); - for ( var logMessageType : getLogMessageTypesOrDefault(logMessageTypes) ) { - getMultiPatternReplacer(logMessageType) - .registerValue(valueToMask, replacement) - .registerValue(encodedValue, replacement); - } - // Also register for stdio masking - any value sensitive enough to mask in logs should be masked in console output - stdioPatternReplacer - .registerValue(valueToMask, replacement) - .registerValue(encodedValue, replacement); - } + LogMaskContext.activeContext().registerValue(sensitivityLevel, valueToMask, replacement, logMessageTypes); return this; } - + /** - * Register a value to be masked in stdio (stdout/stderr), with sensitivity level checking. - * This is specifically for user-provided data and CLI options that should be masked in console output. + * Register a value to be masked in stdio (stdout/stderr) only. Delegates to {@link LogMaskContext#activeContext()}. */ public final LogMaskHelper registerStdioValue(LogSensitivityLevel sensitivityLevel, String valueToMask, String replacement) { - if ( isMaskingNeeded(sensitivityLevel, valueToMask) ) { - stdioPatternReplacer.registerValue(valueToMask, replacement); - } + LogMaskContext.activeContext().registerStdioValue(sensitivityLevel, valueToMask, replacement); return this; } - + /** - * Register a pattern that describes one or more values to be masked, with the given sensitivity - * level and for the given log message type(s). If no log message types are provided, the pattern - * will be registered for all log message types. See {@link MultiPatternReplacer#registerPattern(String, String)} - * for details. + * Register a pattern that describes one or more values to be masked. Delegates to {@link LogMaskContext#activeContext()}. */ public final LogMaskHelper registerPattern(LogSensitivityLevel sensitivityLevel, String patternString, String replacement, LogMessageType... logMessageTypes) { - if ( isMaskingNeededForSensitivityLevel(sensitivityLevel) ) { - for ( var logMessageType : getLogMessageTypesOrDefault(logMessageTypes) ) { - getMultiPatternReplacer(logMessageType).registerPattern(patternString, replacement); - } - } + LogMaskContext.activeContext().registerPattern(sensitivityLevel, patternString, replacement, logMessageTypes); return this; } - + /** + * Register a pattern into {@link LogMaskContext#GLOBAL}, for patterns that must persist + * across all execution contexts (e.g. startup-time HTTP header/response patterns registered + * by {@code FortifyCLIDynamicInitializer}). Unlike {@link #registerPattern}, this always + * targets GLOBAL regardless of what execution context is current. + */ + public final LogMaskHelper registerGlobalPattern(LogSensitivityLevel sensitivityLevel, String patternString, String replacement, LogMessageType... logMessageTypes) { + LogMaskContext.GLOBAL.registerPattern(sensitivityLevel, patternString, replacement, logMessageTypes); + return this; + } /** - * Return either the given log message types, or all log message types if no log message types given. + * Mask the given log message. Applied in two phases: {@link LogMaskContext#GLOBAL} first, + * then the active context if it differs from GLOBAL. The BiConsumer for discovered values + * routes back through {@link #registerValue} so that newly-found values land in + * {@link LogMaskContext#activeContext()} rather than always in GLOBAL. */ - private LogMessageType[] getLogMessageTypesOrDefault(LogMessageType... logMessageTypes) { - return logMessageTypes!=null && logMessageTypes.length!=0 ? logMessageTypes : LogMessageType.all(); + public final String mask(LogMessageType logMessageType, String msg) { + if ( msg == null ) { return null; } + var consumer = discoveredValueConsumer(); + try { + var result = LogMaskContext.GLOBAL.mask(logMessageType, msg, consumer); + var active = LogMaskContext.activeContext(); + if ( active != LogMaskContext.GLOBAL ) { + result = active.mask(logMessageType, result, consumer); + } + return result; + } catch ( FcliBugException e ) { + return ""; + } } - + /** - * Get the {@link MultiPatternReplacer} instance for the given {@link LogMessageType}. + * Mask the given stdio (stdout/stderr) output. Applied in two phases like {@link #mask}. */ - private final MultiPatternReplacer getMultiPatternReplacer(LogMessageType logMessageType) { - return multiPatternReplacers.computeIfAbsent(logMessageType, t->new MultiPatternReplacer()); + public final String maskStdio(String msg) { + if ( StringUtils.isBlank(msg) ) { return msg; } + var consumer = discoveredStdioValueConsumer(); + try { + var result = LogMaskContext.GLOBAL.maskStdio(msg, consumer); + var active = LogMaskContext.activeContext(); + if ( active != LogMaskContext.GLOBAL ) { + result = active.maskStdio(result, consumer); + } + return result; + } catch ( Exception e ) { + return ""; + } } - + + // ------------------------------------------------------------------------- + // Package-visible helpers used by LogMaskContext + // ------------------------------------------------------------------------- + /** * @return true if masking is needed based on comparing the given {@link LogSensitivityLevel} * against the configured {@link LogMaskLevel}, and given value is not blank/too short, * false otherwise. */ - private final boolean isMaskingNeeded(LogSensitivityLevel sensitivityLevel, String valueToMask) { + final boolean isMaskingNeeded(LogSensitivityLevel sensitivityLevel, String valueToMask) { return isMaskingNeededForSensitivityLevel(sensitivityLevel) && StringUtils.isNotBlank(valueToMask) && valueToMask.length()>4; // Avoid masking very short values, as these are not considered secure anyway } - + /** * @return true if masking is needed based on comparing the given {@link LogSensitivityLevel} * against the configured {@link LogMaskLevel}, false otherwise. */ - private final boolean isMaskingNeededForSensitivityLevel(LogSensitivityLevel sensitivityLevel) { - return logMaskLevel.getSensitivityLevels().contains(sensitivityLevel); + final boolean isMaskingNeededForSensitivityLevel(LogSensitivityLevel sensitivityLevel) { + return logMaskLevel != null && logMaskLevel.getSensitivityLevels().contains(sensitivityLevel); } + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + /** - * Mask the given log message using the registered values and patterns for the - * given {@link LogMessageType}. - */ - public final String mask(LogMessageType logMessageType, String msg) { - var multiPattern = getMultiPatternReplacer(logMessageType); - if ( multiPattern==null ) { return null; } - try { - return multiPattern.applyReplacements(msg, - // We want to register replacement values for all log message types, not just - // the log message type on which the replacement was applied. We don't know the - // original sensitivity level here, but if the value was replaced before, it should - // always be replaced again, hence we use sensitivity level 'high'. - (v,r)->registerValue(LogSensitivityLevel.high, v, r)); - } catch ( FcliBugException e ) { - // Exceptions will be eaten by the logging framework, causing the log message to be lost, - // so instead we return a fixed string indicating that an fcli bug occurred. - return ""; - } - } - - /** - * Mask the given stdio (stdout/stderr) output using registered stdio patterns and values. - * This should only be used for masking console output, not log files. - * @param msg the message to mask - * @return masked message with sensitive content replaced + * BiConsumer that routes newly-discovered log-masked values to the active context. + * We don't know the original sensitivity level here, but if the value was replaced + * before it should always be replaced again, so we use sensitivity level 'high'. */ - public final String maskStdio(String msg) { - if ( StringUtils.isBlank(msg) ) { - return msg; - } - try { - return stdioPatternReplacer.applyReplacements(msg, - // Register discovered values for future stdio masking with high sensitivity - (v,r)->registerStdioValue(LogSensitivityLevel.high, v, r)); - } catch ( Exception e ) { - // Never fail masking - return safe fallback - return ""; - } + private BiConsumer discoveredValueConsumer() { + return (v, r) -> registerValue(LogSensitivityLevel.high, v, r); } + private BiConsumer discoveredStdioValueConsumer() { + return (v, r) -> registerStdioValue(LogSensitivityLevel.high, v, r); + } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskSource.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskSource.java index b84552d6d01..37cec2d9fd8 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskSource.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/log/LogMaskSource.java @@ -18,5 +18,5 @@ * @author Ruud Senden */ public enum LogMaskSource { - SESSION, CLI_OPTION, ENV_VAR, HTTP_RESPONSE, GRPC_RESPONSE; + SESSION, CLI_OPTION, ENV_VAR, HTTP_RESPONSE, GRPC_RESPONSE, HTTP_AUTH_HEADER; } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/helper/AbstractSessionHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/helper/AbstractSessionHelper.java index eb37fc7307d..ec3c22960b1 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/helper/AbstractSessionHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/helper/AbstractSessionHelper.java @@ -51,7 +51,7 @@ public final T get(String sessionName, boolean failIfUnavailable) { String sessionDescriptorJson = FcliDataHelper.readSecuredFile(sessionDescriptorPath, failIfUnavailable); T sessionDescriptor = sessionDescriptorJson==null ? null : objectMapper.readValue(sessionDescriptorJson, getSessionDescriptorType()); checkNonExpiredSessionAvailable(sessionName, failIfUnavailable, sessionDescriptor); - return registerLogMasks(sessionDescriptor); + return registerLogMasksAndReturn(sessionDescriptor); } catch ( Exception e ) { FcliDataHelper.deleteFile(sessionDescriptorPath, false); conditionalThrow(failIfUnavailable, ()->new FcliTechnicalException("Error reading session descriptor, please try logging in again", e)); @@ -61,14 +61,28 @@ public final T get(String sessionName, boolean failIfUnavailable) { } } - private final T registerLogMasks(T sessionDescriptor) { - visitFields(sessionDescriptor.getClass(), sessionDescriptor); + private final T registerLogMasksAndReturn(T sessionDescriptor) { + registerLogMasks(sessionDescriptor); return sessionDescriptor; } - + + /** + * Registers log masks for all {@link MaskValue}-annotated fields in the given session + * descriptor (and any nested objects from the {@code com.fortify} package). Delegates + * to {@link LogMaskHelper#registerValue}, so values are registered into + * {@link com.fortify.cli.common.log.LogMaskContext#activeContext()} at call time. + * + *

    Called automatically when a session descriptor is loaded from disk via {@link #get}. + * Must be called explicitly for transient session descriptors (e.g. in the MCP HTTP server) + * after the per-request execution context has been pushed.

    + */ + public static void registerLogMasks(ISessionDescriptor sessionDescriptor) { + visitFields(sessionDescriptor.getClass(), sessionDescriptor); + } + // TODO Remove code duplication in Action::visitFields? // TODO Ideally, this should be done during descriptor deserialization instead - private void visitFields(Class clazz, Object o) { + private static void visitFields(Class clazz, Object o) { if ( clazz!=null && o!=null ) { // Visit fields provided by any superclasses of the given class. visitFields(clazz.getSuperclass(), o); @@ -80,7 +94,7 @@ private void visitFields(Class clazz, Object o) { } @SneakyThrows - private void visitField(Field f, Object o) { + private static void visitField(Field f, Object o) { var value = f.get(o); if ( value!=null ) { var maskAnnotation = f.getAnnotation(MaskValue.class); From 0b54ded1247758fbe160ce87a6ca74341b99db91 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 18 May 2026 16:45:25 +0200 Subject: [PATCH 33/55] ftest: Fix MCP server config file structure --- .../com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy | 3 ++- .../com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy index 8e9e3988de7..e2008cf924f 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy @@ -59,7 +59,8 @@ class FoDMCPServerHttpSpec extends FcliBaseSpec { def port = MCPHttpServerTestHelper.getFreePort() def configPath = Path.of(tempDir, "mcp-http-fod-${port}.yaml") def config = """ - port: ${port} + server: + port: ${port} imports: - ${commonImportActionPath} - ${fodImportActionPath} diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy index cc7d4db74d5..d39677a9d26 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy @@ -64,7 +64,8 @@ class SSCMCPServerHttpSpec extends FcliBaseSpec { def configPath = Path.of(tempDir, "mcp-http-ssc-${port}.yaml") def scSastClientAuthToken = System.getProperty("ft.ssc.client-auth-token") def config = new StringBuilder() - .append("port: ${port}\n") + .append("server:\n") + .append(" port: ${port}\n") .append("imports:\n") .append(" - ${commonImportActionPath}\n") .append(" - ${sscImportActionPath}\n") From defb987cef335883eb9464a7d81783c00fb49541 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 20 May 2026 18:22:06 +0200 Subject: [PATCH 34/55] feat: Add `fcli agent extensions install|update|status` commands --- .../agent/_main/cli/cmd/AgentCommands.java | 2 + .../cli/cmd/AgentExtensionsCommands.java | 29 + .../cmd/AgentExtensionsInstallCommand.java | 91 +++ .../cli/cmd/AgentExtensionsStatusCommand.java | 39 + .../cmd/AgentExtensionsUninstallCommand.java | 57 ++ .../cli/cmd/AgentExtensionsUpdateCommand.java | 91 +++ .../AgentExtensionsAssistantFilterMixin.java | 33 + .../cli/mixin/AgentExtensionsSourceMixin.java | 28 + .../AgentExtensionsAssistantDescriptor.java | 33 + .../AgentExtensionsConditionEvaluator.java | 122 +++ .../AgentExtensionsContentTypeDescriptor.java | 34 + ...AgentExtensionsDistributionDescriptor.java | 34 + .../AgentExtensionsInstallPlanContext.java | 52 ++ .../helper/AgentExtensionsInstaller.java | 731 ++++++++++++++++++ .../AgentExtensionsOutputDescriptor.java | 39 + .../helper/AgentExtensionsPathResolver.java | 91 +++ .../helper/AgentExtensionsSchemaHelper.java | 45 ++ .../helper/AgentExtensionsSourceHandler.java | 257 ++++++ .../AgentExtensionsStateDescriptor.java | 38 + .../AgentExtensionsTargetDescriptor.java | 36 + .../cli/agent/i18n/AgentMessages.properties | 28 + 21 files changed, 1910 insertions(+) create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsCommands.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsInstallCommand.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsStatusCommand.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUninstallCommand.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUpdateCommand.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsAssistantFilterMixin.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsSourceMixin.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsAssistantDescriptor.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsConditionEvaluator.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsContentTypeDescriptor.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsDistributionDescriptor.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstallPlanContext.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstaller.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsOutputDescriptor.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsPathResolver.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSchemaHelper.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSourceHandler.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsStateDescriptor.java create mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsTargetDescriptor.java diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java index 2fed80075e4..bb05d8700bb 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java @@ -14,6 +14,7 @@ import static com.fortify.cli.common.cli.util.FcliModuleCategories.UTIL; +import com.fortify.cli.agent.extensions.cli.cmd.AgentExtensionsCommands; import com.fortify.cli.agent.mcp.cli.cmd.AgentMCPCommands; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; import com.fortify.cli.common.cli.util.FcliModuleCategory; @@ -25,6 +26,7 @@ name = "agent", resourceBundle = "com.fortify.cli.agent.i18n.AgentMessages", subcommands = { + AgentExtensionsCommands.class, AgentMCPCommands.class } ) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsCommands.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsCommands.java new file mode 100644 index 00000000000..fc92ba79930 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsCommands.java @@ -0,0 +1,29 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +@Command( + name = "extensions", + aliases = {"ext"}, + subcommands = { + AgentExtensionsInstallCommand.class, + AgentExtensionsUninstallCommand.class, + AgentExtensionsUpdateCommand.class, + AgentExtensionsStatusCommand.class + } +) +public class AgentExtensionsCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsInstallCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsInstallCommand.java new file mode 100644 index 00000000000..d9af0f36271 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsInstallCommand.java @@ -0,0 +1,91 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.cli.cmd; + +import java.util.Set; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsAssistantFilterMixin; +import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsSourceMixin; +import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller; +import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller.PolicyAction; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = OutputHelperMixins.Install.CMD_NAME) +public class AgentExtensionsInstallCommand extends AbstractOutputCommand + implements IJsonNodeSupplier, IActionCommandResultSupplier { + @Mixin @Getter private OutputHelperMixins.Install outputHelper; + @Mixin private AgentExtensionsAssistantFilterMixin assistantFilter; + @Mixin private AgentExtensionsSourceMixin sourceMixin; + + @Option(names = {"--dir"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.dir") + private String customDir; + + @Option(names = {"--content-types"}, split = ",", paramLabel = "", + descriptionKey = "fcli.agent.extensions.content-types") + private Set contentTypeFilter; + + @Option(names = {"--on-invalid-signature"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.on-invalid-signature", + defaultValue = "fail") + private PolicyAction onInvalidSignature; + + @Option(names = {"--on-unsigned"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.on-unsigned", + defaultValue = "fail") + private PolicyAction onUnsigned; + + @Option(names = {"--on-invalid-version"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.on-invalid-version", + defaultValue = "fail") + private PolicyAction onInvalidVersion; + + @Option(names = {"-y", "--confirm"}, + descriptionKey = "fcli.agent.extensions.confirm") + private boolean confirm; + + @Option(names = {"--dry-run"}, + descriptionKey = "fcli.agent.extensions.dry-run") + private boolean dryRun; + + @Override + public JsonNode getJsonNode() { + return JsonHelper.getObjectMapper().valueToTree( + AgentExtensionsInstaller.install( + sourceMixin.getSource(), + assistantFilter.getAssistants(), + assistantFilter.getExcludeAssistants(), + contentTypeFilter, + customDir, + onInvalidSignature, + onUnsigned, + onInvalidVersion, + dryRun)); + } + + @Override + public boolean isSingular() { return false; } + + @Override + public String getActionCommandResult() { return "INSTALLED"; } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsStatusCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsStatusCommand.java new file mode 100644 index 00000000000..e34c82ef37a --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsStatusCommand.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.cli.cmd; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = OutputHelperMixins.Status.CMD_NAME) +public class AgentExtensionsStatusCommand extends AbstractOutputCommand + implements IJsonNodeSupplier { + @Mixin @Getter private OutputHelperMixins.Status outputHelper; + + @Override + public JsonNode getJsonNode() { + return JsonHelper.getObjectMapper().valueToTree( + AgentExtensionsInstaller.status()); + } + + @Override + public boolean isSingular() { return false; } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUninstallCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUninstallCommand.java new file mode 100644 index 00000000000..ad041eef20d --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUninstallCommand.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.cli.cmd; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsAssistantFilterMixin; +import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = OutputHelperMixins.Uninstall.CMD_NAME) +public class AgentExtensionsUninstallCommand extends AbstractOutputCommand + implements IJsonNodeSupplier, IActionCommandResultSupplier { + @Mixin @Getter private OutputHelperMixins.Uninstall outputHelper; + @Mixin private AgentExtensionsAssistantFilterMixin assistantFilter; + + @Option(names = {"-y", "--confirm"}, + descriptionKey = "fcli.agent.extensions.confirm") + private boolean confirm; + + @Option(names = {"--dry-run"}, + descriptionKey = "fcli.agent.extensions.dry-run") + private boolean dryRun; + + @Override + public JsonNode getJsonNode() { + return JsonHelper.getObjectMapper().valueToTree( + AgentExtensionsInstaller.uninstall( + assistantFilter.getAssistants(), + assistantFilter.getExcludeAssistants(), + dryRun)); + } + + @Override + public boolean isSingular() { return false; } + + @Override + public String getActionCommandResult() { return "REMOVED"; } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUpdateCommand.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUpdateCommand.java new file mode 100644 index 00000000000..c8981e76408 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUpdateCommand.java @@ -0,0 +1,91 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.cli.cmd; + +import java.util.Set; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsAssistantFilterMixin; +import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsSourceMixin; +import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller; +import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller.PolicyAction; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = OutputHelperMixins.Update.CMD_NAME) +public class AgentExtensionsUpdateCommand extends AbstractOutputCommand + implements IJsonNodeSupplier, IActionCommandResultSupplier { + @Mixin @Getter private OutputHelperMixins.Update outputHelper; + @Mixin private AgentExtensionsAssistantFilterMixin assistantFilter; + @Mixin private AgentExtensionsSourceMixin sourceMixin; + + @Option(names = {"--dir"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.dir") + private String customDir; + + @Option(names = {"--content-types"}, split = ",", paramLabel = "", + descriptionKey = "fcli.agent.extensions.content-types") + private Set contentTypeFilter; + + @Option(names = {"--on-invalid-signature"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.on-invalid-signature", + defaultValue = "fail") + private PolicyAction onInvalidSignature; + + @Option(names = {"--on-unsigned"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.on-unsigned", + defaultValue = "fail") + private PolicyAction onUnsigned; + + @Option(names = {"--on-invalid-version"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.on-invalid-version", + defaultValue = "fail") + private PolicyAction onInvalidVersion; + + @Option(names = {"-y", "--confirm"}, + descriptionKey = "fcli.agent.extensions.confirm") + private boolean confirm; + + @Option(names = {"--dry-run"}, + descriptionKey = "fcli.agent.extensions.dry-run") + private boolean dryRun; + + @Override + public JsonNode getJsonNode() { + return JsonHelper.getObjectMapper().valueToTree( + AgentExtensionsInstaller.update( + sourceMixin.getSource(), + assistantFilter.getAssistants(), + assistantFilter.getExcludeAssistants(), + contentTypeFilter, + customDir, + onInvalidSignature, + onUnsigned, + onInvalidVersion, + dryRun)); + } + + @Override + public boolean isSingular() { return false; } + + @Override + public String getActionCommandResult() { return "UPDATED"; } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsAssistantFilterMixin.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsAssistantFilterMixin.java new file mode 100644 index 00000000000..579152805a0 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsAssistantFilterMixin.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.cli.mixin; + +import java.util.Set; + +import lombok.Getter; +import picocli.CommandLine.Option; + +/** + * Mixin providing --assistants and --exclude-assistants options. + */ +public class AgentExtensionsAssistantFilterMixin { + @Getter + @Option(names = {"--assistants"}, split = ",", paramLabel = "", + descriptionKey = "fcli.agent.extensions.assistants") + private Set assistants; + + @Getter + @Option(names = {"--exclude-assistants"}, split = ",", paramLabel = "", + descriptionKey = "fcli.agent.extensions.exclude-assistants") + private Set excludeAssistants; +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsSourceMixin.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsSourceMixin.java new file mode 100644 index 00000000000..b64524b68b1 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsSourceMixin.java @@ -0,0 +1,28 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.cli.mixin; + +import com.fortify.cli.agent.extensions.helper.AgentExtensionsSourceHandler; + +import lombok.Getter; +import picocli.CommandLine.Option; + +/** + * Mixin providing the --source option. + */ +public class AgentExtensionsSourceMixin { + @Getter + @Option(names = {"-s", "--source"}, paramLabel = "", + descriptionKey = "fcli.agent.extensions.source") + private String source = AgentExtensionsSourceHandler.DEFAULT_SOURCE_URL; +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsAssistantDescriptor.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsAssistantDescriptor.java new file mode 100644 index 00000000000..11fd370d447 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsAssistantDescriptor.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Per-assistant configuration from extensions-distribution.yaml. + */ +@Reflectable @NoArgsConstructor @Data +public class AgentExtensionsAssistantDescriptor { + @JsonProperty("display-name") + private String displayName; + @JsonProperty("if") + private Object ifCondition; + private List targets; +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsConditionEvaluator.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsConditionEvaluator.java new file mode 100644 index 00000000000..14c2b8c9840 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsConditionEvaluator.java @@ -0,0 +1,122 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Evaluates declarative conditions from the extensions-distribution.yaml descriptor. + * Supports simple conditions (dir-exists, command-exists, installed) and + * logical operators (any-of, all-of, not). + */ +public final class AgentExtensionsConditionEvaluator { + private static final Logger LOG = LoggerFactory.getLogger(AgentExtensionsConditionEvaluator.class); + private final AgentExtensionsInstallPlanContext planContext; + + public AgentExtensionsConditionEvaluator(AgentExtensionsInstallPlanContext planContext) { + this.planContext = planContext; + } + + /** + * Evaluate a condition object (may be a map with a single condition or operator). + */ + @SuppressWarnings("unchecked") + public boolean evaluate(Object condition) { + if (condition == null) { return true; } + if (condition instanceof Map map) { + return evaluateMap((Map) map); + } + LOG.warn("Unknown condition type: {}", condition.getClass().getName()); + return false; + } + + @SuppressWarnings("unchecked") + private boolean evaluateMap(Map map) { + for (var entry : map.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + switch (key) { + case "dir-exists": + return evaluateDirExists(value); + case "command-exists": + return evaluateCommandExists((String) value); + case "installed": + return evaluateInstalled((String) value); + case "any-of": + return evaluateAnyOf((java.util.List) value); + case "all-of": + return evaluateAllOf((java.util.List) value); + case "not": + return !evaluate(value); + default: + LOG.warn("Unknown condition type '{}', treating as false", key); + return false; + } + } + return true; + } + + private boolean evaluateDirExists(Object value) { + if (value instanceof String s) { + var resolved = AgentExtensionsPathResolver.resolvePath(s); + return resolved != null && Files.isDirectory(resolved); + } else if (value instanceof Map) { + var resolved = AgentExtensionsPathResolver.resolve(value); + return resolved != null && Files.isDirectory(resolved); + } + return false; + } + + private boolean evaluateCommandExists(String command) { + if (StringUtils.isBlank(command)) { return false; } + try { + var pb = new ProcessBuilder("which", command); + pb.redirectErrorStream(true); + var process = pb.start(); + int exitCode = process.waitFor(); + return exitCode == 0; + } catch (IOException | InterruptedException e) { + // On Windows, try 'where' instead + try { + var pb = new ProcessBuilder("where", command); + pb.redirectErrorStream(true); + var process = pb.start(); + int exitCode = process.waitFor(); + return exitCode == 0; + } catch (IOException | InterruptedException e2) { + LOG.debug("Error checking for command '{}': {}", command, e2.getMessage()); + return false; + } + } + } + + private boolean evaluateInstalled(String ref) { + return planContext != null && planContext.isInstalled(ref); + } + + private boolean evaluateAnyOf(java.util.List conditions) { + if (conditions == null) { return false; } + return conditions.stream().anyMatch(this::evaluate); + } + + private boolean evaluateAllOf(java.util.List conditions) { + if (conditions == null) { return false; } + return conditions.stream().allMatch(this::evaluate); + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsContentTypeDescriptor.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsContentTypeDescriptor.java new file mode 100644 index 00000000000..d064bb17a73 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsContentTypeDescriptor.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Content type configuration from extensions-distribution.yaml. + * Defines how entries are discovered within the source archive. + */ +@Reflectable @NoArgsConstructor @Data +public class AgentExtensionsContentTypeDescriptor { + @JsonProperty("source-dir") + private String sourceDir; + private String discover; + @JsonProperty("entry-marker") + private String entryMarker; + @JsonProperty("file-pattern") + private String filePattern; +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsDistributionDescriptor.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsDistributionDescriptor.java new file mode 100644 index 00000000000..c411f38a0aa --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsDistributionDescriptor.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Root descriptor for extensions-distribution.yaml. + */ +@Reflectable @NoArgsConstructor @Data +public class AgentExtensionsDistributionDescriptor { + @JsonProperty("schemaVersion") + private String schemaVersion; + @JsonProperty("content-types") + private Map contentTypes; + @JsonProperty("assistants") + private Map assistants; +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstallPlanContext.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstallPlanContext.java new file mode 100644 index 00000000000..1a4569f4c32 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstallPlanContext.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; + +/** + * Tracks the state of an install/update plan for condition evaluation. + * Used by the condition evaluator to resolve "installed" references. + */ +public final class AgentExtensionsInstallPlanContext { + /** + * Set of "assistantId/contentType" strings representing targets that are + * planned for installation in the current run or were installed previously. + */ + private final Set installedTargets = new HashSet<>(); + + /** + * Set of resolved target directories that have already been processed, + * used for auto-deduplication. + */ + private final Set processedTargetDirs = new HashSet<>(); + + public void markInstalled(String assistantId, String contentType) { + installedTargets.add(assistantId + "/" + contentType); + } + + public boolean isInstalled(String ref) { + return installedTargets.contains(ref); + } + + /** + * Mark a target directory + content type combination as processed. + * @return true if this is a new combination (not yet processed), false if duplicate + */ + public boolean markTargetDir(Path resolvedTargetDir, String contentType) { + var key = resolvedTargetDir.toString() + ":" + contentType; + return processedTargetDirs.add(key); + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstaller.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstaller.java new file mode 100644 index 00000000000..e7b20bfc4f1 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstaller.java @@ -0,0 +1,731 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.crypto.helper.SignatureHelper; +import com.fortify.cli.common.crypto.helper.SignatureHelper.SignatureStatus; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.FcliDataHelper; + +/** + * Core install/update/uninstall/status logic for agent extensions. + */ +public final class AgentExtensionsInstaller { + private static final Logger LOG = LoggerFactory.getLogger(AgentExtensionsInstaller.class); + private static final Path STATE_BASE_PATH = Path.of("state", "agent", "extensions"); + + /** Signature/version policy action */ + public enum PolicyAction { ignore, warn, fail } + + private AgentExtensionsInstaller() {} + + // ──────────────────────────── Install ──────────────────────────── + + public static List install( + String source, + Set assistantFilter, + Set excludeAssistants, + Set contentTypeFilter, + String customDir, + PolicyAction onInvalidSignature, + PolicyAction onUnsigned, + PolicyAction onInvalidVersion, + boolean dryRun) { + try (var sourceHandler = AgentExtensionsSourceHandler.resolve(source)) { + var descriptor = sourceHandler.readDescriptor(); + var sourceVersion = sourceHandler.readSourceVersion(); + + validateSchemaVersion(descriptor.getSchemaVersion(), onInvalidVersion); + verifyDescriptorSignature(sourceHandler, onInvalidSignature, onUnsigned); + + var planContext = new AgentExtensionsInstallPlanContext(); + var conditionEvaluator = new AgentExtensionsConditionEvaluator(planContext); + + var assistants = detectAssistants(descriptor, conditionEvaluator, + assistantFilter, excludeAssistants); + + var plan = buildInstallPlan(descriptor, assistants, sourceHandler, + conditionEvaluator, planContext, contentTypeFilter, customDir, sourceVersion); + + verifyPlanSignatures(plan, sourceHandler, onInvalidSignature, onUnsigned); + + if (!dryRun) { + executePlan(plan, sourceHandler); + } + return plan; + } + } + + // ──────────────────────────── Update ──────────────────────────── + + public static List update( + String source, + Set assistantFilter, + Set excludeAssistants, + Set contentTypeFilter, + String customDir, + PolicyAction onInvalidSignature, + PolicyAction onUnsigned, + PolicyAction onInvalidVersion, + boolean dryRun) { + try (var sourceHandler = AgentExtensionsSourceHandler.resolve(source)) { + var descriptor = sourceHandler.readDescriptor(); + var sourceVersion = sourceHandler.readSourceVersion(); + + validateSchemaVersion(descriptor.getSchemaVersion(), onInvalidVersion); + verifyDescriptorSignature(sourceHandler, onInvalidSignature, onUnsigned); + + var planContext = new AgentExtensionsInstallPlanContext(); + var conditionEvaluator = new AgentExtensionsConditionEvaluator(planContext); + + var assistants = detectAssistants(descriptor, conditionEvaluator, + assistantFilter, excludeAssistants); + + var plan = buildUpdatePlan(descriptor, assistants, sourceHandler, + conditionEvaluator, planContext, contentTypeFilter, customDir, sourceVersion); + + // Only verify signatures for files being installed or updated + var toVerify = plan.stream() + .filter(o -> "INSTALLED".equals(o.getActionResult()) || "UPDATED".equals(o.getActionResult())) + .toList(); + verifyPlanSignatures(toVerify, sourceHandler, onInvalidSignature, onUnsigned); + + if (!dryRun) { + executeUpdatePlan(plan, sourceHandler); + } + return plan; + } + } + + // ──────────────────────────── Uninstall ──────────────────────────── + + public static List uninstall( + Set assistantFilter, + Set excludeAssistants, + boolean dryRun) { + var results = new ArrayList(); + var stateEntries = loadAllStateDescriptors(); + + for (var entry : stateEntries) { + var assistantId = entry.getAssistantId(); + if (!matchesFilter(assistantId, assistantFilter, excludeAssistants)) { continue; } + + if (!dryRun) { + deleteTargetFile(Path.of(entry.getTargetPath())); + deleteStateDescriptor(assistantId, entry.getFile()); + } + results.add(AgentExtensionsOutputDescriptor.builder() + .assistant(entry.getAssistant()) + .assistantId(assistantId) + .file(entry.getFile()) + .contentType(entry.getContentType()) + .targetDir(entry.getTargetDir()) + .targetPath(entry.getTargetPath()) + .sourceVersion(entry.getSourceVersion()) + .actionResult("REMOVED") + .build()); + } + // Clean up empty assistant dirs + if (!dryRun) { + cleanEmptyStateDirs(); + } + return results; + } + + // ──────────────────────────── Status ──────────────────────────── + + public static List status() { + var stateEntries = loadAllStateDescriptors(); + return stateEntries.stream() + .map(s -> AgentExtensionsOutputDescriptor.builder() + .assistant(s.getAssistant()) + .assistantId(s.getAssistantId()) + .file(s.getFile()) + .contentType(s.getContentType()) + .targetDir(s.getTargetDir()) + .targetPath(s.getTargetPath()) + .sourceVersion(s.getSourceVersion()) + .build()) + .toList(); + } + + // ──────────────────────────── Assistant detection ──────────────────────────── + + private static Map detectAssistants( + AgentExtensionsDistributionDescriptor descriptor, + AgentExtensionsConditionEvaluator evaluator, + Set assistantFilter, + Set excludeAssistants) { + var result = new LinkedHashMap(); + if (descriptor.getAssistants() == null) { return result; } + + for (var entry : descriptor.getAssistants().entrySet()) { + var id = entry.getKey(); + var assistant = entry.getValue(); + + if (!matchesFilter(id, assistantFilter, excludeAssistants)) { continue; } + + // If explicitly selected via --assistants, skip detection + boolean explicitlySelected = assistantFilter != null && !assistantFilter.isEmpty(); + if (explicitlySelected || evaluator.evaluate(assistant.getIfCondition())) { + result.put(id, assistant); + } + } + return result; + } + + private static boolean matchesFilter(String id, Set include, Set exclude) { + if (include != null && !include.isEmpty() && !include.contains(id)) { return false; } + if (exclude != null && exclude.contains(id)) { return false; } + return true; + } + + // ──────────────────────────── Install plan ──────────────────────────── + + private static List buildInstallPlan( + AgentExtensionsDistributionDescriptor descriptor, + Map assistants, + AgentExtensionsSourceHandler sourceHandler, + AgentExtensionsConditionEvaluator conditionEvaluator, + AgentExtensionsInstallPlanContext planContext, + Set contentTypeFilter, + String customDir, + String sourceVersion) { + // Phase 1: mark all detected assistants' content types as planned + for (var entry : assistants.entrySet()) { + var assistant = entry.getValue(); + if (assistant.getTargets() == null) { continue; } + for (var target : assistant.getTargets()) { + planContext.markInstalled(entry.getKey(), target.getContentType()); + } + } + + // Phase 2: build the plan, evaluating skip-if + var plan = new ArrayList(); + var explicitlySelected = !assistants.isEmpty() && assistants.keySet().stream() + .anyMatch(k -> true); // simplification: always have detected assistants + + for (var entry : assistants.entrySet()) { + var assistantId = entry.getKey(); + var assistant = entry.getValue(); + if (assistant.getTargets() == null) { continue; } + + for (var target : assistant.getTargets()) { + var contentType = target.getContentType(); + if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } + + var resolvedDir = customDir != null + ? Path.of(customDir).toAbsolutePath().normalize() + : AgentExtensionsPathResolver.resolve(target.getTargetDir()); + if (resolvedDir == null) { continue; } + + // Evaluate skip-if (skip for explicit --assistants selection) + boolean skipIfResult = target.getSkipIf() != null + && conditionEvaluator.evaluate(target.getSkipIf()); + // Auto-dedup: check if same target dir + content type already processed + boolean isDuplicate = !planContext.markTargetDir(resolvedDir, contentType); + + var sourceFiles = discoverSourceFiles(descriptor, target, sourceHandler); + for (var sourceFile : sourceFiles) { + var targetPath = resolvedDir.resolve( + getTargetRelativePath(descriptor, target, sourceFile)); + String action; + if (skipIfResult) { + action = "SKIPPED"; + } else if (isDuplicate) { + action = "SKIPPED"; + } else { + action = "INSTALLED"; + } + plan.add(AgentExtensionsOutputDescriptor.builder() + .assistant(assistant.getDisplayName()) + .assistantId(assistantId) + .file(sourceFile) + .contentType(contentType) + .targetDir(resolvedDir.toString()) + .targetPath(targetPath.toString()) + .sourceVersion(sourceVersion) + .actionResult(action) + .build()); + } + } + } + return plan; + } + + // ──────────────────────────── Update plan ──────────────────────────── + + private static List buildUpdatePlan( + AgentExtensionsDistributionDescriptor descriptor, + Map assistants, + AgentExtensionsSourceHandler sourceHandler, + AgentExtensionsConditionEvaluator conditionEvaluator, + AgentExtensionsInstallPlanContext planContext, + Set contentTypeFilter, + String customDir, + String sourceVersion) { + // First build install plan to know what should exist + var installPlan = buildInstallPlan(descriptor, assistants, sourceHandler, + conditionEvaluator, planContext, contentTypeFilter, customDir, sourceVersion); + + // Load existing state to determine what changed + var existingState = loadAllStateDescriptors(); + var existingByKey = existingState.stream() + .collect(Collectors.toMap( + s -> s.getAssistantId() + ":" + s.getFile(), + s -> s, (a, b) -> a)); + + var plan = new ArrayList(); + var handledKeys = new HashSet(); + + for (var entry : installPlan) { + var key = entry.getAssistantId() + ":" + entry.getFile(); + handledKeys.add(key); + + if ("SKIPPED".equals(entry.getActionResult())) { + plan.add(entry); + continue; + } + + var existing = existingByKey.get(key); + if (existing == null) { + // New file + plan.add(AgentExtensionsOutputDescriptor.builder() + .assistant(entry.getAssistant()) + .assistantId(entry.getAssistantId()) + .file(entry.getFile()) + .contentType(entry.getContentType()) + .targetDir(entry.getTargetDir()) + .targetPath(entry.getTargetPath()) + .sourceVersion(sourceVersion) + .actionResult("INSTALLED") + .build()); + } else if (hasFileChanged(sourceHandler, entry.getFile(), Path.of(existing.getTargetPath()))) { + // Changed file + plan.add(AgentExtensionsOutputDescriptor.builder() + .assistant(entry.getAssistant()) + .assistantId(entry.getAssistantId()) + .file(entry.getFile()) + .contentType(entry.getContentType()) + .targetDir(entry.getTargetDir()) + .targetPath(entry.getTargetPath()) + .sourceVersion(sourceVersion) + .actionResult("UPDATED") + .build()); + } else { + // Unchanged + plan.add(AgentExtensionsOutputDescriptor.builder() + .assistant(entry.getAssistant()) + .assistantId(entry.getAssistantId()) + .file(entry.getFile()) + .contentType(entry.getContentType()) + .targetDir(entry.getTargetDir()) + .targetPath(entry.getTargetPath()) + .sourceVersion(sourceVersion) + .actionResult("UNCHANGED") + .build()); + } + } + + // Files that exist in state but not in source → REMOVED + for (var existing : existingState) { + var key = existing.getAssistantId() + ":" + existing.getFile(); + if (!handledKeys.contains(key) + && matchesFilter(existing.getAssistantId(), + assistants.isEmpty() ? null : assistants.keySet(), null)) { + plan.add(AgentExtensionsOutputDescriptor.builder() + .assistant(existing.getAssistant()) + .assistantId(existing.getAssistantId()) + .file(existing.getFile()) + .contentType(existing.getContentType()) + .targetDir(existing.getTargetDir()) + .targetPath(existing.getTargetPath()) + .sourceVersion(sourceVersion) + .actionResult("REMOVED") + .build()); + } + } + + return plan; + } + + // ──────────────────────────── Content discovery ──────────────────────────── + + private static List discoverSourceFiles( + AgentExtensionsDistributionDescriptor descriptor, + AgentExtensionsTargetDescriptor target, + AgentExtensionsSourceHandler sourceHandler) { + var contentType = target.getContentType(); + var ctDesc = descriptor.getContentTypes() != null + ? descriptor.getContentTypes().get(contentType) : null; + + if (ctDesc == null) { return Collections.emptyList(); } + + var discoverMode = ctDesc.getDiscover(); + if ("explicit".equals(discoverMode)) { + return discoverExplicitEntries(target, sourceHandler); + } + + var sourceDir = ctDesc.getSourceDir(); + if (sourceDir == null || !sourceHandler.exists(sourceDir)) { + return Collections.emptyList(); + } + + if ("directory".equals(discoverMode)) { + return discoverDirectoryEntries(sourceDir, ctDesc.getEntryMarker(), sourceHandler); + } else if ("files".equals(discoverMode)) { + return discoverFileEntries(sourceDir, ctDesc.getFilePattern(), sourceHandler); + } + return Collections.emptyList(); + } + + private static List discoverDirectoryEntries( + String sourceDir, String entryMarker, AgentExtensionsSourceHandler sourceHandler) { + var result = new ArrayList(); + sourceHandler.listDirs(sourceDir).forEach(dir -> { + if (entryMarker != null) { + var markerPath = dir.resolve(entryMarker); + if (!sourceHandler.exists(markerPath.toString())) { return; } + } + // Include all files in this directory tree + sourceHandler.listFiles(dir.toString()).forEach(f -> { + var relative = sourceHandler.getExtractedDir().relativize( + sourceHandler.getExtractedDir().resolve(f)); + result.add(relative.toString()); + }); + }); + return result; + } + + private static List discoverFileEntries( + String sourceDir, String filePattern, AgentExtensionsSourceHandler sourceHandler) { + var result = new ArrayList(); + var globPattern = filePattern != null ? filePattern : "*"; + sourceHandler.listFiles(sourceDir).forEach(f -> { + if (matchesGlob(f.getFileName().toString(), globPattern)) { + result.add(f.toString()); + } + }); + return result; + } + + private static List discoverExplicitEntries( + AgentExtensionsTargetDescriptor target, AgentExtensionsSourceHandler sourceHandler) { + if (target.getSourceEntries() == null) { return Collections.emptyList(); } + var result = new ArrayList(); + for (var entryDir : target.getSourceEntries()) { + if (sourceHandler.exists(entryDir)) { + var entryPath = Path.of(entryDir); + if (Files.isDirectory(sourceHandler.getExtractedDir().resolve(entryDir))) { + sourceHandler.listFiles(entryDir).forEach(f -> result.add(f.toString())); + } else { + result.add(entryDir); + } + } + } + return result; + } + + /** + * Get the relative path for a file within its target directory. + * For directory-discovered content (skills), preserve the directory structure + * under the source-dir. For explicit and file-discovered content, use just the filename + * relative to source-entries dir. + */ + private static String getTargetRelativePath( + AgentExtensionsDistributionDescriptor descriptor, + AgentExtensionsTargetDescriptor target, + String sourceFile) { + var contentType = target.getContentType(); + var ctDesc = descriptor.getContentTypes() != null + ? descriptor.getContentTypes().get(contentType) : null; + + if (ctDesc == null) { return Path.of(sourceFile).getFileName().toString(); } + + var discoverMode = ctDesc.getDiscover(); + if ("explicit".equals(discoverMode) && target.getSourceEntries() != null) { + // For explicit entries, strip the source-entries prefix + for (var entryDir : target.getSourceEntries()) { + if (sourceFile.startsWith(entryDir + "/")) { + return sourceFile.substring(entryDir.length() + 1); + } else if (sourceFile.equals(entryDir)) { + return Path.of(sourceFile).getFileName().toString(); + } + } + return Path.of(sourceFile).getFileName().toString(); + } + + if (ctDesc.getSourceDir() != null && sourceFile.startsWith(ctDesc.getSourceDir() + "/")) { + return sourceFile.substring(ctDesc.getSourceDir().length() + 1); + } + return sourceFile; + } + + private static boolean matchesGlob(String filename, String glob) { + var regex = glob.replace(".", "\\.").replace("*", ".*"); + return filename.matches(regex); + } + + // ──────────────────────────── Signature verification ──────────────────────────── + + private static void verifyDescriptorSignature( + AgentExtensionsSourceHandler sourceHandler, + PolicyAction onInvalidSignature, + PolicyAction onUnsigned) { + var manifest = sourceHandler.readManifest(); + if (manifest == null) { + handlePolicy(onUnsigned, "No manifest.json found — extensions are unsigned"); + return; + } + var sig = manifest.get("extensions-distribution.yaml"); + if (sig == null) { + handlePolicy(onUnsigned, "extensions-distribution.yaml is unsigned (not in manifest)"); + return; + } + verifyFileSignature(sourceHandler, "extensions-distribution.yaml", sig, onInvalidSignature); + } + + private static void verifyPlanSignatures( + List plan, + AgentExtensionsSourceHandler sourceHandler, + PolicyAction onInvalidSignature, + PolicyAction onUnsigned) { + var manifest = sourceHandler.readManifest(); + if (manifest == null) { + // Already handled in verifyDescriptorSignature + return; + } + for (var entry : plan) { + if ("SKIPPED".equals(entry.getActionResult())) { continue; } + var sig = manifest.get(entry.getFile()); + if (sig == null) { + handlePolicy(onUnsigned, "File is unsigned: " + entry.getFile()); + continue; + } + verifyFileSignature(sourceHandler, entry.getFile(), sig, onInvalidSignature); + } + } + + private static void verifyFileSignature( + AgentExtensionsSourceHandler sourceHandler, String file, + String expectedSignature, PolicyAction onInvalidSignature) { + var fileBytes = sourceHandler.readFileBytes(file); + if (fileBytes == null) { + handlePolicy(onInvalidSignature, "File not found for signature verification: " + file); + return; + } + var status = SignatureHelper.fortifySignatureVerifier().verify(fileBytes, expectedSignature); + if (status != SignatureStatus.VALID) { + handlePolicy(onInvalidSignature, + "Invalid signature for " + file + " (status: " + status + ")"); + } + } + + // ──────────────────────────── Schema version ──────────────────────────── + + private static void validateSchemaVersion(String schemaVersion, PolicyAction onInvalidVersion) { + if (!AgentExtensionsSchemaHelper.isCompatible(schemaVersion)) { + handlePolicy(onInvalidVersion, + "Incompatible extensions descriptor schema version: " + schemaVersion + + " (supported: " + AgentExtensionsSchemaHelper.SUPPORTED_SCHEMA_VERSION + ")" + + "\n Consider updating fcli to a newer version."); + } + } + + // ──────────────────────────── Policy handling ──────────────────────────── + + private static void handlePolicy(PolicyAction action, String message) { + if (action == null) { action = PolicyAction.warn; } + switch (action) { + case ignore -> LOG.debug("Ignored: {}", message); + case warn -> LOG.warn("WARNING: {}", message); + case fail -> throw new FcliSimpleException(message); + } + } + + // ──────────────────────────── Plan execution ──────────────────────────── + + private static void executePlan( + List plan, + AgentExtensionsSourceHandler sourceHandler) { + for (var entry : plan) { + if (!"INSTALLED".equals(entry.getActionResult())) { continue; } + installFile(sourceHandler, entry); + } + } + + private static void executeUpdatePlan( + List plan, + AgentExtensionsSourceHandler sourceHandler) { + for (var entry : plan) { + switch (entry.getActionResult()) { + case "INSTALLED", "UPDATED" -> installFile(sourceHandler, entry); + case "REMOVED" -> { + deleteTargetFile(Path.of(entry.getTargetPath())); + deleteStateDescriptor(entry.getAssistantId(), entry.getFile()); + } + } + } + cleanEmptyStateDirs(); + } + + private static void installFile( + AgentExtensionsSourceHandler sourceHandler, + AgentExtensionsOutputDescriptor entry) { + var targetPath = Path.of(entry.getTargetPath()); + var sourceBytes = sourceHandler.readFileBytes(entry.getFile()); + if (sourceBytes == null) { + throw new FcliSimpleException("Source file not found: " + entry.getFile()); + } + try { + Files.createDirectories(targetPath.getParent()); + Files.write(targetPath, sourceBytes); + } catch (IOException e) { + throw new FcliTechnicalException("Error installing file: " + targetPath, e); + } + saveStateDescriptor(entry); + } + + // ──────────────────────────── State management ──────────────────────────── + + private static void saveStateDescriptor(AgentExtensionsOutputDescriptor entry) { + var stateDescriptor = AgentExtensionsStateDescriptor.builder() + .assistant(entry.getAssistant()) + .assistantId(entry.getAssistantId()) + .file(entry.getFile()) + .contentType(entry.getContentType()) + .targetDir(entry.getTargetDir()) + .targetPath(entry.getTargetPath()) + .sourceVersion(entry.getSourceVersion()) + .timestamp(Instant.now().toString()) + .build(); + var relativePath = STATE_BASE_PATH + .resolve(entry.getAssistantId()) + .resolve(entry.getFile() + ".json"); + FcliDataHelper.saveFile(relativePath, stateDescriptor, true); + } + + private static void deleteStateDescriptor(String assistantId, String file) { + var relativePath = STATE_BASE_PATH + .resolve(assistantId) + .resolve(file + ".json"); + FcliDataHelper.deleteFile(relativePath, false); + } + + private static void deleteTargetFile(Path targetPath) { + try { + Files.deleteIfExists(targetPath); + // Clean up empty parent directories + var parent = targetPath.getParent(); + while (parent != null && Files.isDirectory(parent)) { + try (var stream = Files.list(parent)) { + if (stream.findAny().isEmpty()) { + Files.delete(parent); + parent = parent.getParent(); + } else { + break; + } + } + } + } catch (IOException e) { + LOG.warn("Error deleting file: {}", targetPath, e); + } + } + + static List loadAllStateDescriptors() { + var result = new ArrayList(); + var basePath = FcliDataHelper.getFcliHomePath().resolve(STATE_BASE_PATH); + if (!Files.isDirectory(basePath)) { return result; } + + try { + Files.walkFileTree(basePath, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (file.toString().endsWith(".json")) { + try { + var content = Files.readString(file); + var desc = JsonHelper.getObjectMapper() + .readValue(content, AgentExtensionsStateDescriptor.class); + result.add(desc); + } catch (IOException e) { + LOG.warn("Error reading state file: {}", file, e); + } + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + LOG.warn("Error walking state directory: {}", basePath, e); + } + return result; + } + + private static void cleanEmptyStateDirs() { + var basePath = FcliDataHelper.getFcliHomePath().resolve(STATE_BASE_PATH); + if (!Files.isDirectory(basePath)) { return; } + try { + Files.walkFileTree(basePath, new SimpleFileVisitor() { + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + if (!dir.equals(basePath)) { + try (var stream = Files.list(dir)) { + if (stream.findAny().isEmpty()) { + Files.delete(dir); + } + } + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + LOG.debug("Error cleaning empty state dirs", e); + } + } + + private static boolean hasFileChanged( + AgentExtensionsSourceHandler sourceHandler, String sourceFile, Path targetPath) { + if (!Files.isRegularFile(targetPath)) { return true; } + try { + var sourceBytes = sourceHandler.readFileBytes(sourceFile); + var targetBytes = Files.readAllBytes(targetPath); + return sourceBytes == null || !java.util.Arrays.equals(sourceBytes, targetBytes); + } catch (IOException e) { + return true; + } + } + + private static boolean matchesContentTypeFilter(String contentType, Set filter) { + return filter == null || filter.isEmpty() || filter.contains(contentType); + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsOutputDescriptor.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsOutputDescriptor.java new file mode 100644 index 00000000000..c34e4e8bf82 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsOutputDescriptor.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Output record produced by install/update/uninstall/status commands. + * When serialized, the __action__ field is included in the JSON output. + */ +@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data +public class AgentExtensionsOutputDescriptor { + private String assistant; + private String assistantId; + private String file; + private String contentType; + private String targetDir; + private String targetPath; + private String sourceVersion; + @JsonProperty(IActionCommandResultSupplier.actionFieldName) + private String actionResult; +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsPathResolver.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsPathResolver.java new file mode 100644 index 00000000000..72d12ead770 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsPathResolver.java @@ -0,0 +1,91 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import java.nio.file.Path; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; + +import com.fortify.cli.common.util.EnvHelper; + +/** + * Resolves target-dir values from the descriptor: tilde expansion, + * ${VAR} environment variable substitution, and platform map selection. + */ +public final class AgentExtensionsPathResolver { + private AgentExtensionsPathResolver() {} + + /** + * Resolve a target-dir value which may be either a plain string + * or a platform-specific map (linux/darwin/windows keys). + */ + @SuppressWarnings("unchecked") + public static Path resolve(Object targetDir) { + if (targetDir instanceof String s) { + return resolvePath(s); + } else if (targetDir instanceof Map map) { + var platformKey = getPlatformKey(); + var value = (String) ((Map) map).get(platformKey); + if (value == null) { + return null; + } + return resolvePath(value); + } + return null; + } + + /** + * Resolve a single path string: tilde expansion and ${VAR} substitution. + */ + public static Path resolvePath(String path) { + if (StringUtils.isBlank(path)) { return null; } + path = expandTilde(path); + path = expandEnvVars(path); + return Path.of(path).toAbsolutePath().normalize(); + } + + private static String expandTilde(String path) { + if (path.startsWith("~/") || path.equals("~")) { + return EnvHelper.getUserHome() + path.substring(1); + } + return path; + } + + private static String expandEnvVars(String path) { + var sb = new StringBuilder(); + int i = 0; + while (i < path.length()) { + if (path.charAt(i) == '$' && i + 1 < path.length() && path.charAt(i + 1) == '{') { + int end = path.indexOf('}', i + 2); + if (end > 0) { + var varName = path.substring(i + 2, end); + var varValue = EnvHelper.env(varName); + sb.append(varValue != null ? varValue : ""); + i = end + 1; + continue; + } + } + sb.append(path.charAt(i)); + i++; + } + return sb.toString(); + } + + static String getPlatformKey() { + if (SystemUtils.IS_OS_WINDOWS) { return "windows"; } + if (SystemUtils.IS_OS_MAC) { return "darwin"; } + return "linux"; + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSchemaHelper.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSchemaHelper.java new file mode 100644 index 00000000000..7afb275c413 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSchemaHelper.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import com.fortify.cli.common.util.SemVer; + +/** + * Validates schema version compatibility for extensions-distribution.yaml. + */ +public final class AgentExtensionsSchemaHelper { + /** The schema version supported by this version of fcli */ + public static final String SUPPORTED_SCHEMA_VERSION = "1.0.0"; + + private AgentExtensionsSchemaHelper() {} + + /** + * Check whether the descriptor's schema version is compatible with + * this version of fcli. + * @return true if compatible (same major, fcli minor >= descriptor minor) + */ + public static boolean isCompatible(String descriptorSchemaVersion) { + // Normalize: if version is "1.0", treat as "1.0.0" + var normalized = normalizeVersion(descriptorSchemaVersion); + var supported = new SemVer(SUPPORTED_SCHEMA_VERSION); + var descriptor = new SemVer(normalized); + return supported.isCompatibleWith(descriptor); + } + + private static String normalizeVersion(String version) { + if (version == null) { return "0.0.0"; } + var parts = version.split("\\."); + if (parts.length == 2) { return version + ".0"; } + return version; + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSourceHandler.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSourceHandler.java new file mode 100644 index 00000000000..f794396ecd5 --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSourceHandler.java @@ -0,0 +1,257 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; +import java.util.zip.ZipFile; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.rest.unirest.UnirestHelper; + +/** + * Resolves and provides access to extension source contents. + * Supports local zip files, local directories, and remote URLs. + */ +public final class AgentExtensionsSourceHandler implements AutoCloseable { + public static final String DEFAULT_SOURCE_URL = + "https://github.com/fortify/skills/releases/download/latest/fortify-agent-extensions.zip"; + private static final Logger LOG = LoggerFactory.getLogger(AgentExtensionsSourceHandler.class); + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + private final Path extractedDir; + private final boolean tempDir; + + private AgentExtensionsSourceHandler(Path extractedDir, boolean tempDir) { + this.extractedDir = extractedDir; + this.tempDir = tempDir; + } + + /** + * Resolve a source string to a source handler. + * @param source local zip path, local directory path, or remote URL + */ + public static AgentExtensionsSourceHandler resolve(String source) { + if (StringUtils.isBlank(source)) { + source = DEFAULT_SOURCE_URL; + } + var path = Path.of(source); + if (Files.isDirectory(path)) { + return new AgentExtensionsSourceHandler(path.toAbsolutePath(), false); + } + if (Files.isRegularFile(path)) { + return fromZipFile(path); + } + if (isUrl(source)) { + return fromUrl(source); + } + throw new FcliSimpleException("Source not found or unsupported: " + source); + } + + private static boolean isUrl(String source) { + return source.startsWith("http://") || source.startsWith("https://"); + } + + private static AgentExtensionsSourceHandler fromUrl(String url) { + try { + var tempZip = Files.createTempFile("fcli-extensions-", ".zip"); + try { + UnirestHelper.download("agent", url, tempZip.toFile()); + var handler = fromZipFile(tempZip); + return handler; + } finally { + Files.deleteIfExists(tempZip); + } + } catch (IOException e) { + throw new FcliTechnicalException("Error downloading extensions from " + url, e); + } + } + + private static AgentExtensionsSourceHandler fromZipFile(Path zipPath) { + try { + var tempDir = Files.createTempDirectory("fcli-extensions-"); + try (var zipFile = new ZipFile(zipPath.toFile())) { + var entries = zipFile.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + var entryPath = tempDir.resolve(normalizePath(entry.getName())); + // Validate path traversal + if (!entryPath.normalize().startsWith(tempDir)) { + throw new FcliSimpleException("Zip entry contains path traversal: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(entryPath); + } else { + Files.createDirectories(entryPath.getParent()); + try (InputStream is = zipFile.getInputStream(entry)) { + Files.copy(is, entryPath); + } + } + } + } + return new AgentExtensionsSourceHandler(tempDir, true); + } catch (IOException e) { + throw new FcliTechnicalException("Error extracting extensions zip: " + zipPath, e); + } + } + + /** + * Normalize a zip entry path: strip leading "./" prefix. + */ + private static String normalizePath(String path) { + if (path.startsWith("./")) { return path.substring(2); } + return path; + } + + public Path getExtractedDir() { + return extractedDir; + } + + /** + * Read and parse the extensions-distribution.yaml descriptor. + */ + public AgentExtensionsDistributionDescriptor readDescriptor() { + var descriptorPath = extractedDir.resolve("extensions-distribution.yaml"); + if (!Files.isRegularFile(descriptorPath)) { + throw new FcliSimpleException("extensions-distribution.yaml not found in source"); + } + try { + return YAML_MAPPER.readValue(descriptorPath.toFile(), AgentExtensionsDistributionDescriptor.class); + } catch (IOException e) { + throw new FcliTechnicalException("Error reading extensions-distribution.yaml", e); + } + } + + /** + * Read the source version from version.txt. Returns "unknown" if not found. + */ + public String readSourceVersion() { + var versionPath = extractedDir.resolve("version.txt"); + if (!Files.isRegularFile(versionPath)) { + return "unknown"; + } + try { + return Files.readString(versionPath).trim(); + } catch (IOException e) { + LOG.warn("Error reading version.txt, defaulting to 'unknown'", e); + return "unknown"; + } + } + + /** + * Read and parse manifest.json for signature verification. + * @return map of normalized file path → RSA-SHA256 signature, or null if no manifest + */ + public Map readManifest() { + var manifestPath = extractedDir.resolve("manifest.json"); + if (!Files.isRegularFile(manifestPath)) { + return null; + } + try { + var objectMapper = JsonHelper.getObjectMapper(); + var entries = objectMapper.readValue(manifestPath.toFile(), + new TypeReference>() {}); + var result = new HashMap(); + for (var entry : entries) { + result.put(normalizePath(entry.path), entry.rsa_sha256); + } + return result; + } catch (IOException e) { + throw new FcliTechnicalException("Error reading manifest.json", e); + } + } + + /** + * Get a file's bytes from the source. + */ + public byte[] readFileBytes(String relativePath) { + var filePath = extractedDir.resolve(relativePath); + if (!Files.isRegularFile(filePath)) { return null; } + try { + return Files.readAllBytes(filePath); + } catch (IOException e) { + throw new FcliTechnicalException("Error reading file: " + relativePath, e); + } + } + + /** + * Check if a relative path exists in the source. + */ + public boolean exists(String relativePath) { + return Files.exists(extractedDir.resolve(relativePath)); + } + + /** + * List files within a directory in the source. + */ + public Stream listFiles(String relativePath) { + var dir = extractedDir.resolve(relativePath); + if (!Files.isDirectory(dir)) { return Stream.empty(); } + try { + return Files.walk(dir) + .filter(Files::isRegularFile) + .map(p -> extractedDir.relativize(p)); + } catch (IOException e) { + throw new FcliTechnicalException("Error listing files in: " + relativePath, e); + } + } + + /** + * List immediate subdirectories within a directory. + */ + public Stream listDirs(String relativePath) { + var dir = extractedDir.resolve(relativePath); + if (!Files.isDirectory(dir)) { return Stream.empty(); } + try { + return Files.list(dir).filter(Files::isDirectory); + } catch (IOException e) { + throw new FcliTechnicalException("Error listing dirs in: " + relativePath, e); + } + } + + @Override + public void close() { + if (tempDir) { + try { + Files.walk(extractedDir) + .sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } catch (IOException e) { + LOG.debug("Error cleaning up temp dir: {}", extractedDir, e); + } + } + } + + @com.formkiq.graalvm.annotations.Reflectable + private static class ManifestEntry { + public String path; + public String rsa_sha256; + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsStateDescriptor.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsStateDescriptor.java new file mode 100644 index 00000000000..ace2ef7f22b --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsStateDescriptor.java @@ -0,0 +1,38 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Per-file state descriptor stored under the fcli state directory. + * Tracks what was installed, where, and from which source version. + */ +@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data +public class AgentExtensionsStateDescriptor { + private String assistant; + private String assistantId; + private String file; + private String contentType; + private String targetDir; + private String targetPath; + private String sourceVersion; + @JsonProperty("timestamp") + private String timestamp; +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsTargetDescriptor.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsTargetDescriptor.java new file mode 100644 index 00000000000..16ec07b1acf --- /dev/null +++ b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsTargetDescriptor.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.agent.extensions.helper; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Per-target configuration within an assistant definition. + */ +@Reflectable @NoArgsConstructor @Data +public class AgentExtensionsTargetDescriptor { + @JsonProperty("content-type") + private String contentType; + @JsonProperty("target-dir") + private Object targetDir; + @JsonProperty("skip-if") + private Object skipIf; + @JsonProperty("source-entries") + private List sourceEntries; +} diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties index fe925263d49..fc01cba0799 100644 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties +++ b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties @@ -1,6 +1,34 @@ fcli.agent.usage.header = (PREVIEW) Manage AI assistant integrations fcli.agent.usage.description = Manage AI-related functionality like MCP servers and skills. +# fcli agent extensions +fcli.agent.extensions.usage.header = (PREVIEW) Manage Fortify extensions for AI coding assistants +fcli.agent.extensions.usage.description = Install, update, uninstall, or check status of Fortify extensions (skills, agents, plugins) for AI coding assistants like Claude Code, GitHub Copilot, OpenAI Codex, and Gemini CLI. +fcli.agent.extensions.install.usage.header = Install Fortify extensions to detected coding assistants +fcli.agent.extensions.install.usage.description = Download and install Fortify extensions (skills, agents, plugins) to detected AI coding assistants. By default, extensions are downloaded from the official Fortify releases. Use --source to specify a local zip, directory, or alternative URL. +fcli.agent.extensions.install.output.table.args = assistant,file,__action__ +fcli.agent.extensions.uninstall.usage.header = Remove installed Fortify extensions from coding assistants +fcli.agent.extensions.uninstall.usage.description = Remove previously installed Fortify extensions from AI coding assistant directories and clean up fcli state. +fcli.agent.extensions.uninstall.output.table.args = assistant,file,__action__ +fcli.agent.extensions.update.usage.header = Update installed Fortify extensions +fcli.agent.extensions.update.usage.description = Update installed extensions: add new files, update changed files, and remove files no longer in the source. Can also be used for first-time install. +fcli.agent.extensions.update.output.table.args = assistant,file,__action__ +fcli.agent.extensions.status.usage.header = Show current extension installation state +fcli.agent.extensions.status.usage.description = Display the current installation state of Fortify extensions per coding assistant. +fcli.agent.extensions.status.output.table.args = assistant,file + +# Shared option descriptions for extensions commands +fcli.agent.extensions.assistants = Restrict to specific assistants (e.g., --assistants claude,copilot). Default: all detected. +fcli.agent.extensions.exclude-assistants = Exclude specific assistants from the operation. +fcli.agent.extensions.source = Extensions source: local zip file, local directory, or remote URL. Default: official Fortify releases. +fcli.agent.extensions.dir = Install to a specific directory instead of auto-detected locations. Requires --content-type. Bypasses assistant detection. +fcli.agent.extensions.content-types = Filter by content type: skills, agents, plugins. Default: all. Required with --dir. +fcli.agent.extensions.on-invalid-signature = Action on invalid signature: ignore, warn, fail. Default: fail. +fcli.agent.extensions.on-unsigned = Action on unsigned content: ignore, warn, fail. Default: fail. +fcli.agent.extensions.on-invalid-version = Action on incompatible schema version: ignore, warn, fail. Default: fail. +fcli.agent.extensions.confirm = Skip confirmation prompt. +fcli.agent.extensions.dry-run = Show what would be done without performing any changes. + # fcli agent mcp fcli.agent.mcp.usage.header = (PREVIEW) Manage fcli MCP server commands for AI assistants fcli.agent.mcp.usage.description = Start fcli MCP servers for AI assistants, and generate HTTP MCP server config templates. From 517931a9b0ad60c8e3114f34f38be1fed87eb641 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 21 May 2026 18:22:22 +0200 Subject: [PATCH 35/55] chore: Major refactoring (output, tool-definitions, new commands) --- .../cli/mixin/AgentExtensionsSourceMixin.java | 28 - .../AgentExtensionsConditionEvaluator.java | 122 --- .../AgentExtensionsInstallPlanContext.java | 52 -- .../helper/AgentExtensionsInstaller.java | 731 ----------------- .../helper/AgentExtensionsSchemaHelper.java | 45 -- .../helper/AgentExtensionsSourceHandler.java | 257 ------ .../cli/agent/i18n/AgentMessages.properties | 66 -- .../build.gradle.kts | 2 + .../_main/cli/cmd/AiAssistCommands.java} | 17 +- .../cli/cmd/AiAssistExtensionsCommands.java} | 14 +- .../AiAssistExtensionsInstallCommand.java} | 56 +- ...AssistExtensionsListAssistantsCommand.java | 44 ++ ...iAssistExtensionsListInstalledCommand.java | 39 + ...iAssistExtensionsListVersionsCommand.java} | 12 +- .../AiAssistExtensionsUninstallCommand.java} | 16 +- .../cmd/AiAssistExtensionsUpdateCommand.java} | 56 +- ...AssistExtensionsAssistantFilterMixin.java} | 8 +- ...iAssistExtensionsAssistantDescriptor.java} | 6 +- ...stExtensionsAssistantOutputDescriptor.java | 34 + .../AiAssistExtensionsConditionEvaluator.java | 188 +++++ ...tExtensionsContentManifestDescriptor.java} | 14 +- ...ssistExtensionsContentTypeDescriptor.java} | 10 +- ...ssistExtensionsDistributionDescriptor.java | 31 + .../AiAssistExtensionsInstallPlanContext.java | 60 ++ .../helper/AiAssistExtensionsInstaller.java | 737 ++++++++++++++++++ .../AiAssistExtensionsOutputDescriptor.java} | 17 +- .../AiAssistExtensionsPathResolver.java} | 22 +- .../AiAssistExtensionsSourceHandler.java | 265 +++++++ .../AiAssistExtensionsStateDescriptor.java} | 19 +- .../AiAssistExtensionsTargetDescriptor.java} | 10 +- ...sistExtensionsVersionOutputDescriptor.java | 30 + .../mcp/cli/cmd/AiAssistMCPCommands.java} | 10 +- .../AiAssistMCPCreateHttpConfigCommand.java} | 4 +- .../cli/cmd/AiAssistMCPStartHttpCommand.java} | 16 +- .../cmd/AiAssistMCPStartStdioCommand.java} | 30 +- .../MCPImportedActionMcpSpecsFactory.java | 10 +- .../ai_assist}/mcp/helper/MCPJobManager.java | 4 +- .../mcp/helper/MCPReflectConfigGenerator.java | 2 +- .../arg/AbstractMCPToolArgHandlerFcli.java | 2 +- .../mcp/helper/arg/IMCPToolArgHandler.java | 2 +- .../arg/MCPToolArgHandlerActionOption.java | 2 +- .../arg/MCPToolArgHandlerFcliOption.java | 2 +- .../arg/MCPToolArgHandlerFcliParam.java | 2 +- .../helper/arg/MCPToolArgHandlerPaging.java | 4 +- .../helper/arg/MCPToolArgHandlerQuery.java | 2 +- .../mcp/helper/arg/MCPToolArgHandlers.java | 2 +- .../JdkHttpServerMcpStatelessTransport.java | 6 +- .../http/MCPServerHttpAuthHeaderParser.java | 2 +- .../mcp/helper/http/MCPServerHttpConfig.java | 2 +- .../http/MCPServerHttpConfigLoader.java | 2 +- ...CPServerHttpSessionDescriptorResolver.java | 2 +- .../mcp/helper/http/ParsedAuthorization.java | 2 +- .../runner/AbstractMCPToolFcliRunner.java | 6 +- .../mcp/helper/runner/IMCPToolRunner.java | 2 +- .../runner/MCPResourceFcliRunnerFunction.java | 2 +- .../helper/runner/MCPToolAsyncJobManager.java | 4 +- .../helper/runner/MCPToolFcliPagedHelper.java | 6 +- .../runner/MCPToolFcliRunnerAction.java | 6 +- .../runner/MCPToolFcliRunnerFunction.java | 4 +- .../MCPToolFcliRunnerFunctionStreaming.java | 6 +- .../runner/MCPToolFcliRunnerHelper.java | 2 +- .../runner/MCPToolFcliRunnerPlainText.java | 6 +- .../runner/MCPToolFcliRunnerRecords.java | 6 +- .../runner/MCPToolFcliRunnerRecordsPaged.java | 6 +- .../mcp/helper/runner/MCPToolResult.java | 2 +- .../i18n/AiAssistMessages.properties | 72 ++ .../mcp/config/mcp-http-config-fod.yaml | 0 .../mcp/config/mcp-http-config-ssc.yaml | 0 ...rverHttpSessionDescriptorResolverTest.java | 2 +- ...CPToolFcliRunnerFunctionStreamingTest.java | 2 +- .../mcp/helper/runner/MCPToolResultTest.java | 2 +- .../cli/agent/mcp/unit/MCPJobManagerTest.java | 4 +- .../unit/MCPServerHttpConfigLoaderTest.java | 6 +- .../mcp/unit/MCPToolArgHandlersTest.java | 4 +- .../unit/MCPToolFcliRunnerRecordsTest.java | 8 +- fcli-core/fcli-app/build.gradle.kts | 4 +- .../app/_main/cli/cmd/FCLIRootCommands.java | 4 +- .../tool/_common/helper/ToolDependency.java | 79 -- .../helper/ToolDefinitionsHelper.java | 214 +++-- .../cli/cmd/ToolSCClientInstallCommand.java | 3 +- gradle.properties | 2 +- 81 files changed, 1905 insertions(+), 1677 deletions(-) delete mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsSourceMixin.java delete mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsConditionEvaluator.java delete mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstallPlanContext.java delete mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstaller.java delete mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSchemaHelper.java delete mode 100644 fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSourceHandler.java delete mode 100644 fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties rename fcli-core/{fcli-agent => fcli-ai-assist}/build.gradle.kts (71%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/_main/cli/cmd/AiAssistCommands.java} (63%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsCommands.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.java} (60%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsInstallCommand.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsInstallCommand.java} (56%) create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListAssistantsCommand.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListInstalledCommand.java rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsStatusCommand.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java} (74%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUninstallCommand.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java} (75%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUpdateCommand.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUpdateCommand.java} (56%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsAssistantFilterMixin.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/mixin/AiAssistExtensionsAssistantFilterMixin.java} (78%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsAssistantDescriptor.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.java} (84%) create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantOutputDescriptor.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsDistributionDescriptor.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentManifestDescriptor.java} (65%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsContentTypeDescriptor.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentTypeDescriptor.java} (75%) create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsDistributionDescriptor.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanContext.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsOutputDescriptor.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsOutputDescriptor.java} (66%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsPathResolver.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java} (79%) create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsStateDescriptor.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateDescriptor.java} (66%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsTargetDescriptor.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDescriptor.java} (81%) create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsVersionOutputDescriptor.java rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCommands.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCommands.java} (72%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCreateHttpConfigCommand.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java} (95%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java} (93%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java} (95%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/MCPImportedActionMcpSpecsFactory.java (95%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/MCPJobManager.java (99%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/MCPReflectConfigGenerator.java (98%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java (99%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/arg/IMCPToolArgHandler.java (95%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/arg/MCPToolArgHandlerActionOption.java (98%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/arg/MCPToolArgHandlerFcliOption.java (97%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/arg/MCPToolArgHandlerFcliParam.java (97%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/arg/MCPToolArgHandlerPaging.java (95%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/arg/MCPToolArgHandlerQuery.java (99%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/arg/MCPToolArgHandlers.java (98%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java (97%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/http/MCPServerHttpAuthHeaderParser.java (99%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/http/MCPServerHttpConfig.java (99%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/http/MCPServerHttpConfigLoader.java (98%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java (99%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/http/ParsedAuthorization.java (97%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/AbstractMCPToolFcliRunner.java (90%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/IMCPToolRunner.java (95%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPResourceFcliRunnerFunction.java (98%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolAsyncJobManager.java (98%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolFcliPagedHelper.java (98%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolFcliRunnerAction.java (94%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolFcliRunnerFunction.java (96%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java (95%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolFcliRunnerHelper.java (98%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolFcliRunnerPlainText.java (93%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolFcliRunnerRecords.java (93%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java (92%) rename fcli-core/{fcli-agent/src/main/java/com/fortify/cli/agent => fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist}/mcp/helper/runner/MCPToolResult.java (99%) create mode 100644 fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties rename fcli-core/{fcli-agent/src/main/resources/com/fortify/cli/agent => fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist}/mcp/config/mcp-http-config-fod.yaml (100%) rename fcli-core/{fcli-agent/src/main/resources/com/fortify/cli/agent => fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist}/mcp/config/mcp-http-config-ssc.yaml (100%) rename fcli-core/{fcli-agent => fcli-ai-assist}/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java (99%) rename fcli-core/{fcli-agent => fcli-ai-assist}/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java (96%) rename fcli-core/{fcli-agent => fcli-ai-assist}/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java (95%) rename fcli-core/{fcli-agent => fcli-ai-assist}/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java (99%) rename fcli-core/{fcli-agent => fcli-ai-assist}/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java (93%) rename fcli-core/{fcli-agent => fcli-ai-assist}/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java (98%) rename fcli-core/{fcli-agent => fcli-ai-assist}/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java (94%) delete mode 100644 fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolDependency.java diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsSourceMixin.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsSourceMixin.java deleted file mode 100644 index b64524b68b1..00000000000 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsSourceMixin.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.agent.extensions.cli.mixin; - -import com.fortify.cli.agent.extensions.helper.AgentExtensionsSourceHandler; - -import lombok.Getter; -import picocli.CommandLine.Option; - -/** - * Mixin providing the --source option. - */ -public class AgentExtensionsSourceMixin { - @Getter - @Option(names = {"-s", "--source"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.source") - private String source = AgentExtensionsSourceHandler.DEFAULT_SOURCE_URL; -} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsConditionEvaluator.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsConditionEvaluator.java deleted file mode 100644 index 14c2b8c9840..00000000000 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsConditionEvaluator.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.agent.extensions.helper; - -import java.io.IOException; -import java.nio.file.Files; -import java.util.Map; - -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Evaluates declarative conditions from the extensions-distribution.yaml descriptor. - * Supports simple conditions (dir-exists, command-exists, installed) and - * logical operators (any-of, all-of, not). - */ -public final class AgentExtensionsConditionEvaluator { - private static final Logger LOG = LoggerFactory.getLogger(AgentExtensionsConditionEvaluator.class); - private final AgentExtensionsInstallPlanContext planContext; - - public AgentExtensionsConditionEvaluator(AgentExtensionsInstallPlanContext planContext) { - this.planContext = planContext; - } - - /** - * Evaluate a condition object (may be a map with a single condition or operator). - */ - @SuppressWarnings("unchecked") - public boolean evaluate(Object condition) { - if (condition == null) { return true; } - if (condition instanceof Map map) { - return evaluateMap((Map) map); - } - LOG.warn("Unknown condition type: {}", condition.getClass().getName()); - return false; - } - - @SuppressWarnings("unchecked") - private boolean evaluateMap(Map map) { - for (var entry : map.entrySet()) { - var key = entry.getKey(); - var value = entry.getValue(); - switch (key) { - case "dir-exists": - return evaluateDirExists(value); - case "command-exists": - return evaluateCommandExists((String) value); - case "installed": - return evaluateInstalled((String) value); - case "any-of": - return evaluateAnyOf((java.util.List) value); - case "all-of": - return evaluateAllOf((java.util.List) value); - case "not": - return !evaluate(value); - default: - LOG.warn("Unknown condition type '{}', treating as false", key); - return false; - } - } - return true; - } - - private boolean evaluateDirExists(Object value) { - if (value instanceof String s) { - var resolved = AgentExtensionsPathResolver.resolvePath(s); - return resolved != null && Files.isDirectory(resolved); - } else if (value instanceof Map) { - var resolved = AgentExtensionsPathResolver.resolve(value); - return resolved != null && Files.isDirectory(resolved); - } - return false; - } - - private boolean evaluateCommandExists(String command) { - if (StringUtils.isBlank(command)) { return false; } - try { - var pb = new ProcessBuilder("which", command); - pb.redirectErrorStream(true); - var process = pb.start(); - int exitCode = process.waitFor(); - return exitCode == 0; - } catch (IOException | InterruptedException e) { - // On Windows, try 'where' instead - try { - var pb = new ProcessBuilder("where", command); - pb.redirectErrorStream(true); - var process = pb.start(); - int exitCode = process.waitFor(); - return exitCode == 0; - } catch (IOException | InterruptedException e2) { - LOG.debug("Error checking for command '{}': {}", command, e2.getMessage()); - return false; - } - } - } - - private boolean evaluateInstalled(String ref) { - return planContext != null && planContext.isInstalled(ref); - } - - private boolean evaluateAnyOf(java.util.List conditions) { - if (conditions == null) { return false; } - return conditions.stream().anyMatch(this::evaluate); - } - - private boolean evaluateAllOf(java.util.List conditions) { - if (conditions == null) { return false; } - return conditions.stream().allMatch(this::evaluate); - } -} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstallPlanContext.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstallPlanContext.java deleted file mode 100644 index 1a4569f4c32..00000000000 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstallPlanContext.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.agent.extensions.helper; - -import java.nio.file.Path; -import java.util.HashSet; -import java.util.Set; - -/** - * Tracks the state of an install/update plan for condition evaluation. - * Used by the condition evaluator to resolve "installed" references. - */ -public final class AgentExtensionsInstallPlanContext { - /** - * Set of "assistantId/contentType" strings representing targets that are - * planned for installation in the current run or were installed previously. - */ - private final Set installedTargets = new HashSet<>(); - - /** - * Set of resolved target directories that have already been processed, - * used for auto-deduplication. - */ - private final Set processedTargetDirs = new HashSet<>(); - - public void markInstalled(String assistantId, String contentType) { - installedTargets.add(assistantId + "/" + contentType); - } - - public boolean isInstalled(String ref) { - return installedTargets.contains(ref); - } - - /** - * Mark a target directory + content type combination as processed. - * @return true if this is a new combination (not yet processed), false if duplicate - */ - public boolean markTargetDir(Path resolvedTargetDir, String contentType) { - var key = resolvedTargetDir.toString() + ":" + contentType; - return processedTargetDirs.add(key); - } -} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstaller.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstaller.java deleted file mode 100644 index e7b20bfc4f1..00000000000 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsInstaller.java +++ /dev/null @@ -1,731 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.agent.extensions.helper; - -import java.io.IOException; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fortify.cli.common.crypto.helper.SignatureHelper; -import com.fortify.cli.common.crypto.helper.SignatureHelper.SignatureStatus; -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.exception.FcliTechnicalException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.util.FcliDataHelper; - -/** - * Core install/update/uninstall/status logic for agent extensions. - */ -public final class AgentExtensionsInstaller { - private static final Logger LOG = LoggerFactory.getLogger(AgentExtensionsInstaller.class); - private static final Path STATE_BASE_PATH = Path.of("state", "agent", "extensions"); - - /** Signature/version policy action */ - public enum PolicyAction { ignore, warn, fail } - - private AgentExtensionsInstaller() {} - - // ──────────────────────────── Install ──────────────────────────── - - public static List install( - String source, - Set assistantFilter, - Set excludeAssistants, - Set contentTypeFilter, - String customDir, - PolicyAction onInvalidSignature, - PolicyAction onUnsigned, - PolicyAction onInvalidVersion, - boolean dryRun) { - try (var sourceHandler = AgentExtensionsSourceHandler.resolve(source)) { - var descriptor = sourceHandler.readDescriptor(); - var sourceVersion = sourceHandler.readSourceVersion(); - - validateSchemaVersion(descriptor.getSchemaVersion(), onInvalidVersion); - verifyDescriptorSignature(sourceHandler, onInvalidSignature, onUnsigned); - - var planContext = new AgentExtensionsInstallPlanContext(); - var conditionEvaluator = new AgentExtensionsConditionEvaluator(planContext); - - var assistants = detectAssistants(descriptor, conditionEvaluator, - assistantFilter, excludeAssistants); - - var plan = buildInstallPlan(descriptor, assistants, sourceHandler, - conditionEvaluator, planContext, contentTypeFilter, customDir, sourceVersion); - - verifyPlanSignatures(plan, sourceHandler, onInvalidSignature, onUnsigned); - - if (!dryRun) { - executePlan(plan, sourceHandler); - } - return plan; - } - } - - // ──────────────────────────── Update ──────────────────────────── - - public static List update( - String source, - Set assistantFilter, - Set excludeAssistants, - Set contentTypeFilter, - String customDir, - PolicyAction onInvalidSignature, - PolicyAction onUnsigned, - PolicyAction onInvalidVersion, - boolean dryRun) { - try (var sourceHandler = AgentExtensionsSourceHandler.resolve(source)) { - var descriptor = sourceHandler.readDescriptor(); - var sourceVersion = sourceHandler.readSourceVersion(); - - validateSchemaVersion(descriptor.getSchemaVersion(), onInvalidVersion); - verifyDescriptorSignature(sourceHandler, onInvalidSignature, onUnsigned); - - var planContext = new AgentExtensionsInstallPlanContext(); - var conditionEvaluator = new AgentExtensionsConditionEvaluator(planContext); - - var assistants = detectAssistants(descriptor, conditionEvaluator, - assistantFilter, excludeAssistants); - - var plan = buildUpdatePlan(descriptor, assistants, sourceHandler, - conditionEvaluator, planContext, contentTypeFilter, customDir, sourceVersion); - - // Only verify signatures for files being installed or updated - var toVerify = plan.stream() - .filter(o -> "INSTALLED".equals(o.getActionResult()) || "UPDATED".equals(o.getActionResult())) - .toList(); - verifyPlanSignatures(toVerify, sourceHandler, onInvalidSignature, onUnsigned); - - if (!dryRun) { - executeUpdatePlan(plan, sourceHandler); - } - return plan; - } - } - - // ──────────────────────────── Uninstall ──────────────────────────── - - public static List uninstall( - Set assistantFilter, - Set excludeAssistants, - boolean dryRun) { - var results = new ArrayList(); - var stateEntries = loadAllStateDescriptors(); - - for (var entry : stateEntries) { - var assistantId = entry.getAssistantId(); - if (!matchesFilter(assistantId, assistantFilter, excludeAssistants)) { continue; } - - if (!dryRun) { - deleteTargetFile(Path.of(entry.getTargetPath())); - deleteStateDescriptor(assistantId, entry.getFile()); - } - results.add(AgentExtensionsOutputDescriptor.builder() - .assistant(entry.getAssistant()) - .assistantId(assistantId) - .file(entry.getFile()) - .contentType(entry.getContentType()) - .targetDir(entry.getTargetDir()) - .targetPath(entry.getTargetPath()) - .sourceVersion(entry.getSourceVersion()) - .actionResult("REMOVED") - .build()); - } - // Clean up empty assistant dirs - if (!dryRun) { - cleanEmptyStateDirs(); - } - return results; - } - - // ──────────────────────────── Status ──────────────────────────── - - public static List status() { - var stateEntries = loadAllStateDescriptors(); - return stateEntries.stream() - .map(s -> AgentExtensionsOutputDescriptor.builder() - .assistant(s.getAssistant()) - .assistantId(s.getAssistantId()) - .file(s.getFile()) - .contentType(s.getContentType()) - .targetDir(s.getTargetDir()) - .targetPath(s.getTargetPath()) - .sourceVersion(s.getSourceVersion()) - .build()) - .toList(); - } - - // ──────────────────────────── Assistant detection ──────────────────────────── - - private static Map detectAssistants( - AgentExtensionsDistributionDescriptor descriptor, - AgentExtensionsConditionEvaluator evaluator, - Set assistantFilter, - Set excludeAssistants) { - var result = new LinkedHashMap(); - if (descriptor.getAssistants() == null) { return result; } - - for (var entry : descriptor.getAssistants().entrySet()) { - var id = entry.getKey(); - var assistant = entry.getValue(); - - if (!matchesFilter(id, assistantFilter, excludeAssistants)) { continue; } - - // If explicitly selected via --assistants, skip detection - boolean explicitlySelected = assistantFilter != null && !assistantFilter.isEmpty(); - if (explicitlySelected || evaluator.evaluate(assistant.getIfCondition())) { - result.put(id, assistant); - } - } - return result; - } - - private static boolean matchesFilter(String id, Set include, Set exclude) { - if (include != null && !include.isEmpty() && !include.contains(id)) { return false; } - if (exclude != null && exclude.contains(id)) { return false; } - return true; - } - - // ──────────────────────────── Install plan ──────────────────────────── - - private static List buildInstallPlan( - AgentExtensionsDistributionDescriptor descriptor, - Map assistants, - AgentExtensionsSourceHandler sourceHandler, - AgentExtensionsConditionEvaluator conditionEvaluator, - AgentExtensionsInstallPlanContext planContext, - Set contentTypeFilter, - String customDir, - String sourceVersion) { - // Phase 1: mark all detected assistants' content types as planned - for (var entry : assistants.entrySet()) { - var assistant = entry.getValue(); - if (assistant.getTargets() == null) { continue; } - for (var target : assistant.getTargets()) { - planContext.markInstalled(entry.getKey(), target.getContentType()); - } - } - - // Phase 2: build the plan, evaluating skip-if - var plan = new ArrayList(); - var explicitlySelected = !assistants.isEmpty() && assistants.keySet().stream() - .anyMatch(k -> true); // simplification: always have detected assistants - - for (var entry : assistants.entrySet()) { - var assistantId = entry.getKey(); - var assistant = entry.getValue(); - if (assistant.getTargets() == null) { continue; } - - for (var target : assistant.getTargets()) { - var contentType = target.getContentType(); - if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } - - var resolvedDir = customDir != null - ? Path.of(customDir).toAbsolutePath().normalize() - : AgentExtensionsPathResolver.resolve(target.getTargetDir()); - if (resolvedDir == null) { continue; } - - // Evaluate skip-if (skip for explicit --assistants selection) - boolean skipIfResult = target.getSkipIf() != null - && conditionEvaluator.evaluate(target.getSkipIf()); - // Auto-dedup: check if same target dir + content type already processed - boolean isDuplicate = !planContext.markTargetDir(resolvedDir, contentType); - - var sourceFiles = discoverSourceFiles(descriptor, target, sourceHandler); - for (var sourceFile : sourceFiles) { - var targetPath = resolvedDir.resolve( - getTargetRelativePath(descriptor, target, sourceFile)); - String action; - if (skipIfResult) { - action = "SKIPPED"; - } else if (isDuplicate) { - action = "SKIPPED"; - } else { - action = "INSTALLED"; - } - plan.add(AgentExtensionsOutputDescriptor.builder() - .assistant(assistant.getDisplayName()) - .assistantId(assistantId) - .file(sourceFile) - .contentType(contentType) - .targetDir(resolvedDir.toString()) - .targetPath(targetPath.toString()) - .sourceVersion(sourceVersion) - .actionResult(action) - .build()); - } - } - } - return plan; - } - - // ──────────────────────────── Update plan ──────────────────────────── - - private static List buildUpdatePlan( - AgentExtensionsDistributionDescriptor descriptor, - Map assistants, - AgentExtensionsSourceHandler sourceHandler, - AgentExtensionsConditionEvaluator conditionEvaluator, - AgentExtensionsInstallPlanContext planContext, - Set contentTypeFilter, - String customDir, - String sourceVersion) { - // First build install plan to know what should exist - var installPlan = buildInstallPlan(descriptor, assistants, sourceHandler, - conditionEvaluator, planContext, contentTypeFilter, customDir, sourceVersion); - - // Load existing state to determine what changed - var existingState = loadAllStateDescriptors(); - var existingByKey = existingState.stream() - .collect(Collectors.toMap( - s -> s.getAssistantId() + ":" + s.getFile(), - s -> s, (a, b) -> a)); - - var plan = new ArrayList(); - var handledKeys = new HashSet(); - - for (var entry : installPlan) { - var key = entry.getAssistantId() + ":" + entry.getFile(); - handledKeys.add(key); - - if ("SKIPPED".equals(entry.getActionResult())) { - plan.add(entry); - continue; - } - - var existing = existingByKey.get(key); - if (existing == null) { - // New file - plan.add(AgentExtensionsOutputDescriptor.builder() - .assistant(entry.getAssistant()) - .assistantId(entry.getAssistantId()) - .file(entry.getFile()) - .contentType(entry.getContentType()) - .targetDir(entry.getTargetDir()) - .targetPath(entry.getTargetPath()) - .sourceVersion(sourceVersion) - .actionResult("INSTALLED") - .build()); - } else if (hasFileChanged(sourceHandler, entry.getFile(), Path.of(existing.getTargetPath()))) { - // Changed file - plan.add(AgentExtensionsOutputDescriptor.builder() - .assistant(entry.getAssistant()) - .assistantId(entry.getAssistantId()) - .file(entry.getFile()) - .contentType(entry.getContentType()) - .targetDir(entry.getTargetDir()) - .targetPath(entry.getTargetPath()) - .sourceVersion(sourceVersion) - .actionResult("UPDATED") - .build()); - } else { - // Unchanged - plan.add(AgentExtensionsOutputDescriptor.builder() - .assistant(entry.getAssistant()) - .assistantId(entry.getAssistantId()) - .file(entry.getFile()) - .contentType(entry.getContentType()) - .targetDir(entry.getTargetDir()) - .targetPath(entry.getTargetPath()) - .sourceVersion(sourceVersion) - .actionResult("UNCHANGED") - .build()); - } - } - - // Files that exist in state but not in source → REMOVED - for (var existing : existingState) { - var key = existing.getAssistantId() + ":" + existing.getFile(); - if (!handledKeys.contains(key) - && matchesFilter(existing.getAssistantId(), - assistants.isEmpty() ? null : assistants.keySet(), null)) { - plan.add(AgentExtensionsOutputDescriptor.builder() - .assistant(existing.getAssistant()) - .assistantId(existing.getAssistantId()) - .file(existing.getFile()) - .contentType(existing.getContentType()) - .targetDir(existing.getTargetDir()) - .targetPath(existing.getTargetPath()) - .sourceVersion(sourceVersion) - .actionResult("REMOVED") - .build()); - } - } - - return plan; - } - - // ──────────────────────────── Content discovery ──────────────────────────── - - private static List discoverSourceFiles( - AgentExtensionsDistributionDescriptor descriptor, - AgentExtensionsTargetDescriptor target, - AgentExtensionsSourceHandler sourceHandler) { - var contentType = target.getContentType(); - var ctDesc = descriptor.getContentTypes() != null - ? descriptor.getContentTypes().get(contentType) : null; - - if (ctDesc == null) { return Collections.emptyList(); } - - var discoverMode = ctDesc.getDiscover(); - if ("explicit".equals(discoverMode)) { - return discoverExplicitEntries(target, sourceHandler); - } - - var sourceDir = ctDesc.getSourceDir(); - if (sourceDir == null || !sourceHandler.exists(sourceDir)) { - return Collections.emptyList(); - } - - if ("directory".equals(discoverMode)) { - return discoverDirectoryEntries(sourceDir, ctDesc.getEntryMarker(), sourceHandler); - } else if ("files".equals(discoverMode)) { - return discoverFileEntries(sourceDir, ctDesc.getFilePattern(), sourceHandler); - } - return Collections.emptyList(); - } - - private static List discoverDirectoryEntries( - String sourceDir, String entryMarker, AgentExtensionsSourceHandler sourceHandler) { - var result = new ArrayList(); - sourceHandler.listDirs(sourceDir).forEach(dir -> { - if (entryMarker != null) { - var markerPath = dir.resolve(entryMarker); - if (!sourceHandler.exists(markerPath.toString())) { return; } - } - // Include all files in this directory tree - sourceHandler.listFiles(dir.toString()).forEach(f -> { - var relative = sourceHandler.getExtractedDir().relativize( - sourceHandler.getExtractedDir().resolve(f)); - result.add(relative.toString()); - }); - }); - return result; - } - - private static List discoverFileEntries( - String sourceDir, String filePattern, AgentExtensionsSourceHandler sourceHandler) { - var result = new ArrayList(); - var globPattern = filePattern != null ? filePattern : "*"; - sourceHandler.listFiles(sourceDir).forEach(f -> { - if (matchesGlob(f.getFileName().toString(), globPattern)) { - result.add(f.toString()); - } - }); - return result; - } - - private static List discoverExplicitEntries( - AgentExtensionsTargetDescriptor target, AgentExtensionsSourceHandler sourceHandler) { - if (target.getSourceEntries() == null) { return Collections.emptyList(); } - var result = new ArrayList(); - for (var entryDir : target.getSourceEntries()) { - if (sourceHandler.exists(entryDir)) { - var entryPath = Path.of(entryDir); - if (Files.isDirectory(sourceHandler.getExtractedDir().resolve(entryDir))) { - sourceHandler.listFiles(entryDir).forEach(f -> result.add(f.toString())); - } else { - result.add(entryDir); - } - } - } - return result; - } - - /** - * Get the relative path for a file within its target directory. - * For directory-discovered content (skills), preserve the directory structure - * under the source-dir. For explicit and file-discovered content, use just the filename - * relative to source-entries dir. - */ - private static String getTargetRelativePath( - AgentExtensionsDistributionDescriptor descriptor, - AgentExtensionsTargetDescriptor target, - String sourceFile) { - var contentType = target.getContentType(); - var ctDesc = descriptor.getContentTypes() != null - ? descriptor.getContentTypes().get(contentType) : null; - - if (ctDesc == null) { return Path.of(sourceFile).getFileName().toString(); } - - var discoverMode = ctDesc.getDiscover(); - if ("explicit".equals(discoverMode) && target.getSourceEntries() != null) { - // For explicit entries, strip the source-entries prefix - for (var entryDir : target.getSourceEntries()) { - if (sourceFile.startsWith(entryDir + "/")) { - return sourceFile.substring(entryDir.length() + 1); - } else if (sourceFile.equals(entryDir)) { - return Path.of(sourceFile).getFileName().toString(); - } - } - return Path.of(sourceFile).getFileName().toString(); - } - - if (ctDesc.getSourceDir() != null && sourceFile.startsWith(ctDesc.getSourceDir() + "/")) { - return sourceFile.substring(ctDesc.getSourceDir().length() + 1); - } - return sourceFile; - } - - private static boolean matchesGlob(String filename, String glob) { - var regex = glob.replace(".", "\\.").replace("*", ".*"); - return filename.matches(regex); - } - - // ──────────────────────────── Signature verification ──────────────────────────── - - private static void verifyDescriptorSignature( - AgentExtensionsSourceHandler sourceHandler, - PolicyAction onInvalidSignature, - PolicyAction onUnsigned) { - var manifest = sourceHandler.readManifest(); - if (manifest == null) { - handlePolicy(onUnsigned, "No manifest.json found — extensions are unsigned"); - return; - } - var sig = manifest.get("extensions-distribution.yaml"); - if (sig == null) { - handlePolicy(onUnsigned, "extensions-distribution.yaml is unsigned (not in manifest)"); - return; - } - verifyFileSignature(sourceHandler, "extensions-distribution.yaml", sig, onInvalidSignature); - } - - private static void verifyPlanSignatures( - List plan, - AgentExtensionsSourceHandler sourceHandler, - PolicyAction onInvalidSignature, - PolicyAction onUnsigned) { - var manifest = sourceHandler.readManifest(); - if (manifest == null) { - // Already handled in verifyDescriptorSignature - return; - } - for (var entry : plan) { - if ("SKIPPED".equals(entry.getActionResult())) { continue; } - var sig = manifest.get(entry.getFile()); - if (sig == null) { - handlePolicy(onUnsigned, "File is unsigned: " + entry.getFile()); - continue; - } - verifyFileSignature(sourceHandler, entry.getFile(), sig, onInvalidSignature); - } - } - - private static void verifyFileSignature( - AgentExtensionsSourceHandler sourceHandler, String file, - String expectedSignature, PolicyAction onInvalidSignature) { - var fileBytes = sourceHandler.readFileBytes(file); - if (fileBytes == null) { - handlePolicy(onInvalidSignature, "File not found for signature verification: " + file); - return; - } - var status = SignatureHelper.fortifySignatureVerifier().verify(fileBytes, expectedSignature); - if (status != SignatureStatus.VALID) { - handlePolicy(onInvalidSignature, - "Invalid signature for " + file + " (status: " + status + ")"); - } - } - - // ──────────────────────────── Schema version ──────────────────────────── - - private static void validateSchemaVersion(String schemaVersion, PolicyAction onInvalidVersion) { - if (!AgentExtensionsSchemaHelper.isCompatible(schemaVersion)) { - handlePolicy(onInvalidVersion, - "Incompatible extensions descriptor schema version: " + schemaVersion - + " (supported: " + AgentExtensionsSchemaHelper.SUPPORTED_SCHEMA_VERSION + ")" - + "\n Consider updating fcli to a newer version."); - } - } - - // ──────────────────────────── Policy handling ──────────────────────────── - - private static void handlePolicy(PolicyAction action, String message) { - if (action == null) { action = PolicyAction.warn; } - switch (action) { - case ignore -> LOG.debug("Ignored: {}", message); - case warn -> LOG.warn("WARNING: {}", message); - case fail -> throw new FcliSimpleException(message); - } - } - - // ──────────────────────────── Plan execution ──────────────────────────── - - private static void executePlan( - List plan, - AgentExtensionsSourceHandler sourceHandler) { - for (var entry : plan) { - if (!"INSTALLED".equals(entry.getActionResult())) { continue; } - installFile(sourceHandler, entry); - } - } - - private static void executeUpdatePlan( - List plan, - AgentExtensionsSourceHandler sourceHandler) { - for (var entry : plan) { - switch (entry.getActionResult()) { - case "INSTALLED", "UPDATED" -> installFile(sourceHandler, entry); - case "REMOVED" -> { - deleteTargetFile(Path.of(entry.getTargetPath())); - deleteStateDescriptor(entry.getAssistantId(), entry.getFile()); - } - } - } - cleanEmptyStateDirs(); - } - - private static void installFile( - AgentExtensionsSourceHandler sourceHandler, - AgentExtensionsOutputDescriptor entry) { - var targetPath = Path.of(entry.getTargetPath()); - var sourceBytes = sourceHandler.readFileBytes(entry.getFile()); - if (sourceBytes == null) { - throw new FcliSimpleException("Source file not found: " + entry.getFile()); - } - try { - Files.createDirectories(targetPath.getParent()); - Files.write(targetPath, sourceBytes); - } catch (IOException e) { - throw new FcliTechnicalException("Error installing file: " + targetPath, e); - } - saveStateDescriptor(entry); - } - - // ──────────────────────────── State management ──────────────────────────── - - private static void saveStateDescriptor(AgentExtensionsOutputDescriptor entry) { - var stateDescriptor = AgentExtensionsStateDescriptor.builder() - .assistant(entry.getAssistant()) - .assistantId(entry.getAssistantId()) - .file(entry.getFile()) - .contentType(entry.getContentType()) - .targetDir(entry.getTargetDir()) - .targetPath(entry.getTargetPath()) - .sourceVersion(entry.getSourceVersion()) - .timestamp(Instant.now().toString()) - .build(); - var relativePath = STATE_BASE_PATH - .resolve(entry.getAssistantId()) - .resolve(entry.getFile() + ".json"); - FcliDataHelper.saveFile(relativePath, stateDescriptor, true); - } - - private static void deleteStateDescriptor(String assistantId, String file) { - var relativePath = STATE_BASE_PATH - .resolve(assistantId) - .resolve(file + ".json"); - FcliDataHelper.deleteFile(relativePath, false); - } - - private static void deleteTargetFile(Path targetPath) { - try { - Files.deleteIfExists(targetPath); - // Clean up empty parent directories - var parent = targetPath.getParent(); - while (parent != null && Files.isDirectory(parent)) { - try (var stream = Files.list(parent)) { - if (stream.findAny().isEmpty()) { - Files.delete(parent); - parent = parent.getParent(); - } else { - break; - } - } - } - } catch (IOException e) { - LOG.warn("Error deleting file: {}", targetPath, e); - } - } - - static List loadAllStateDescriptors() { - var result = new ArrayList(); - var basePath = FcliDataHelper.getFcliHomePath().resolve(STATE_BASE_PATH); - if (!Files.isDirectory(basePath)) { return result; } - - try { - Files.walkFileTree(basePath, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - if (file.toString().endsWith(".json")) { - try { - var content = Files.readString(file); - var desc = JsonHelper.getObjectMapper() - .readValue(content, AgentExtensionsStateDescriptor.class); - result.add(desc); - } catch (IOException e) { - LOG.warn("Error reading state file: {}", file, e); - } - } - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - LOG.warn("Error walking state directory: {}", basePath, e); - } - return result; - } - - private static void cleanEmptyStateDirs() { - var basePath = FcliDataHelper.getFcliHomePath().resolve(STATE_BASE_PATH); - if (!Files.isDirectory(basePath)) { return; } - try { - Files.walkFileTree(basePath, new SimpleFileVisitor() { - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { - if (!dir.equals(basePath)) { - try (var stream = Files.list(dir)) { - if (stream.findAny().isEmpty()) { - Files.delete(dir); - } - } - } - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - LOG.debug("Error cleaning empty state dirs", e); - } - } - - private static boolean hasFileChanged( - AgentExtensionsSourceHandler sourceHandler, String sourceFile, Path targetPath) { - if (!Files.isRegularFile(targetPath)) { return true; } - try { - var sourceBytes = sourceHandler.readFileBytes(sourceFile); - var targetBytes = Files.readAllBytes(targetPath); - return sourceBytes == null || !java.util.Arrays.equals(sourceBytes, targetBytes); - } catch (IOException e) { - return true; - } - } - - private static boolean matchesContentTypeFilter(String contentType, Set filter) { - return filter == null || filter.isEmpty() || filter.contains(contentType); - } -} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSchemaHelper.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSchemaHelper.java deleted file mode 100644 index 7afb275c413..00000000000 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSchemaHelper.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.agent.extensions.helper; - -import com.fortify.cli.common.util.SemVer; - -/** - * Validates schema version compatibility for extensions-distribution.yaml. - */ -public final class AgentExtensionsSchemaHelper { - /** The schema version supported by this version of fcli */ - public static final String SUPPORTED_SCHEMA_VERSION = "1.0.0"; - - private AgentExtensionsSchemaHelper() {} - - /** - * Check whether the descriptor's schema version is compatible with - * this version of fcli. - * @return true if compatible (same major, fcli minor >= descriptor minor) - */ - public static boolean isCompatible(String descriptorSchemaVersion) { - // Normalize: if version is "1.0", treat as "1.0.0" - var normalized = normalizeVersion(descriptorSchemaVersion); - var supported = new SemVer(SUPPORTED_SCHEMA_VERSION); - var descriptor = new SemVer(normalized); - return supported.isCompatibleWith(descriptor); - } - - private static String normalizeVersion(String version) { - if (version == null) { return "0.0.0"; } - var parts = version.split("\\."); - if (parts.length == 2) { return version + ".0"; } - return version; - } -} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSourceHandler.java b/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSourceHandler.java deleted file mode 100644 index f794396ecd5..00000000000 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsSourceHandler.java +++ /dev/null @@ -1,257 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.agent.extensions.helper; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.HashMap; -import java.util.Map; -import java.util.stream.Stream; -import java.util.zip.ZipFile; - -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.exception.FcliTechnicalException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.rest.unirest.UnirestHelper; - -/** - * Resolves and provides access to extension source contents. - * Supports local zip files, local directories, and remote URLs. - */ -public final class AgentExtensionsSourceHandler implements AutoCloseable { - public static final String DEFAULT_SOURCE_URL = - "https://github.com/fortify/skills/releases/download/latest/fortify-agent-extensions.zip"; - private static final Logger LOG = LoggerFactory.getLogger(AgentExtensionsSourceHandler.class); - private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); - - private final Path extractedDir; - private final boolean tempDir; - - private AgentExtensionsSourceHandler(Path extractedDir, boolean tempDir) { - this.extractedDir = extractedDir; - this.tempDir = tempDir; - } - - /** - * Resolve a source string to a source handler. - * @param source local zip path, local directory path, or remote URL - */ - public static AgentExtensionsSourceHandler resolve(String source) { - if (StringUtils.isBlank(source)) { - source = DEFAULT_SOURCE_URL; - } - var path = Path.of(source); - if (Files.isDirectory(path)) { - return new AgentExtensionsSourceHandler(path.toAbsolutePath(), false); - } - if (Files.isRegularFile(path)) { - return fromZipFile(path); - } - if (isUrl(source)) { - return fromUrl(source); - } - throw new FcliSimpleException("Source not found or unsupported: " + source); - } - - private static boolean isUrl(String source) { - return source.startsWith("http://") || source.startsWith("https://"); - } - - private static AgentExtensionsSourceHandler fromUrl(String url) { - try { - var tempZip = Files.createTempFile("fcli-extensions-", ".zip"); - try { - UnirestHelper.download("agent", url, tempZip.toFile()); - var handler = fromZipFile(tempZip); - return handler; - } finally { - Files.deleteIfExists(tempZip); - } - } catch (IOException e) { - throw new FcliTechnicalException("Error downloading extensions from " + url, e); - } - } - - private static AgentExtensionsSourceHandler fromZipFile(Path zipPath) { - try { - var tempDir = Files.createTempDirectory("fcli-extensions-"); - try (var zipFile = new ZipFile(zipPath.toFile())) { - var entries = zipFile.entries(); - while (entries.hasMoreElements()) { - var entry = entries.nextElement(); - var entryPath = tempDir.resolve(normalizePath(entry.getName())); - // Validate path traversal - if (!entryPath.normalize().startsWith(tempDir)) { - throw new FcliSimpleException("Zip entry contains path traversal: " + entry.getName()); - } - if (entry.isDirectory()) { - Files.createDirectories(entryPath); - } else { - Files.createDirectories(entryPath.getParent()); - try (InputStream is = zipFile.getInputStream(entry)) { - Files.copy(is, entryPath); - } - } - } - } - return new AgentExtensionsSourceHandler(tempDir, true); - } catch (IOException e) { - throw new FcliTechnicalException("Error extracting extensions zip: " + zipPath, e); - } - } - - /** - * Normalize a zip entry path: strip leading "./" prefix. - */ - private static String normalizePath(String path) { - if (path.startsWith("./")) { return path.substring(2); } - return path; - } - - public Path getExtractedDir() { - return extractedDir; - } - - /** - * Read and parse the extensions-distribution.yaml descriptor. - */ - public AgentExtensionsDistributionDescriptor readDescriptor() { - var descriptorPath = extractedDir.resolve("extensions-distribution.yaml"); - if (!Files.isRegularFile(descriptorPath)) { - throw new FcliSimpleException("extensions-distribution.yaml not found in source"); - } - try { - return YAML_MAPPER.readValue(descriptorPath.toFile(), AgentExtensionsDistributionDescriptor.class); - } catch (IOException e) { - throw new FcliTechnicalException("Error reading extensions-distribution.yaml", e); - } - } - - /** - * Read the source version from version.txt. Returns "unknown" if not found. - */ - public String readSourceVersion() { - var versionPath = extractedDir.resolve("version.txt"); - if (!Files.isRegularFile(versionPath)) { - return "unknown"; - } - try { - return Files.readString(versionPath).trim(); - } catch (IOException e) { - LOG.warn("Error reading version.txt, defaulting to 'unknown'", e); - return "unknown"; - } - } - - /** - * Read and parse manifest.json for signature verification. - * @return map of normalized file path → RSA-SHA256 signature, or null if no manifest - */ - public Map readManifest() { - var manifestPath = extractedDir.resolve("manifest.json"); - if (!Files.isRegularFile(manifestPath)) { - return null; - } - try { - var objectMapper = JsonHelper.getObjectMapper(); - var entries = objectMapper.readValue(manifestPath.toFile(), - new TypeReference>() {}); - var result = new HashMap(); - for (var entry : entries) { - result.put(normalizePath(entry.path), entry.rsa_sha256); - } - return result; - } catch (IOException e) { - throw new FcliTechnicalException("Error reading manifest.json", e); - } - } - - /** - * Get a file's bytes from the source. - */ - public byte[] readFileBytes(String relativePath) { - var filePath = extractedDir.resolve(relativePath); - if (!Files.isRegularFile(filePath)) { return null; } - try { - return Files.readAllBytes(filePath); - } catch (IOException e) { - throw new FcliTechnicalException("Error reading file: " + relativePath, e); - } - } - - /** - * Check if a relative path exists in the source. - */ - public boolean exists(String relativePath) { - return Files.exists(extractedDir.resolve(relativePath)); - } - - /** - * List files within a directory in the source. - */ - public Stream listFiles(String relativePath) { - var dir = extractedDir.resolve(relativePath); - if (!Files.isDirectory(dir)) { return Stream.empty(); } - try { - return Files.walk(dir) - .filter(Files::isRegularFile) - .map(p -> extractedDir.relativize(p)); - } catch (IOException e) { - throw new FcliTechnicalException("Error listing files in: " + relativePath, e); - } - } - - /** - * List immediate subdirectories within a directory. - */ - public Stream listDirs(String relativePath) { - var dir = extractedDir.resolve(relativePath); - if (!Files.isDirectory(dir)) { return Stream.empty(); } - try { - return Files.list(dir).filter(Files::isDirectory); - } catch (IOException e) { - throw new FcliTechnicalException("Error listing dirs in: " + relativePath, e); - } - } - - @Override - public void close() { - if (tempDir) { - try { - Files.walk(extractedDir) - .sorted(Comparator.reverseOrder()) - .forEach(p -> { - try { Files.deleteIfExists(p); } catch (IOException ignored) {} - }); - } catch (IOException e) { - LOG.debug("Error cleaning up temp dir: {}", extractedDir, e); - } - } - } - - @com.formkiq.graalvm.annotations.Reflectable - private static class ManifestEntry { - public String path; - public String rsa_sha256; - } -} diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties b/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties deleted file mode 100644 index fc01cba0799..00000000000 --- a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/i18n/AgentMessages.properties +++ /dev/null @@ -1,66 +0,0 @@ -fcli.agent.usage.header = (PREVIEW) Manage AI assistant integrations -fcli.agent.usage.description = Manage AI-related functionality like MCP servers and skills. - -# fcli agent extensions -fcli.agent.extensions.usage.header = (PREVIEW) Manage Fortify extensions for AI coding assistants -fcli.agent.extensions.usage.description = Install, update, uninstall, or check status of Fortify extensions (skills, agents, plugins) for AI coding assistants like Claude Code, GitHub Copilot, OpenAI Codex, and Gemini CLI. -fcli.agent.extensions.install.usage.header = Install Fortify extensions to detected coding assistants -fcli.agent.extensions.install.usage.description = Download and install Fortify extensions (skills, agents, plugins) to detected AI coding assistants. By default, extensions are downloaded from the official Fortify releases. Use --source to specify a local zip, directory, or alternative URL. -fcli.agent.extensions.install.output.table.args = assistant,file,__action__ -fcli.agent.extensions.uninstall.usage.header = Remove installed Fortify extensions from coding assistants -fcli.agent.extensions.uninstall.usage.description = Remove previously installed Fortify extensions from AI coding assistant directories and clean up fcli state. -fcli.agent.extensions.uninstall.output.table.args = assistant,file,__action__ -fcli.agent.extensions.update.usage.header = Update installed Fortify extensions -fcli.agent.extensions.update.usage.description = Update installed extensions: add new files, update changed files, and remove files no longer in the source. Can also be used for first-time install. -fcli.agent.extensions.update.output.table.args = assistant,file,__action__ -fcli.agent.extensions.status.usage.header = Show current extension installation state -fcli.agent.extensions.status.usage.description = Display the current installation state of Fortify extensions per coding assistant. -fcli.agent.extensions.status.output.table.args = assistant,file - -# Shared option descriptions for extensions commands -fcli.agent.extensions.assistants = Restrict to specific assistants (e.g., --assistants claude,copilot). Default: all detected. -fcli.agent.extensions.exclude-assistants = Exclude specific assistants from the operation. -fcli.agent.extensions.source = Extensions source: local zip file, local directory, or remote URL. Default: official Fortify releases. -fcli.agent.extensions.dir = Install to a specific directory instead of auto-detected locations. Requires --content-type. Bypasses assistant detection. -fcli.agent.extensions.content-types = Filter by content type: skills, agents, plugins. Default: all. Required with --dir. -fcli.agent.extensions.on-invalid-signature = Action on invalid signature: ignore, warn, fail. Default: fail. -fcli.agent.extensions.on-unsigned = Action on unsigned content: ignore, warn, fail. Default: fail. -fcli.agent.extensions.on-invalid-version = Action on incompatible schema version: ignore, warn, fail. Default: fail. -fcli.agent.extensions.confirm = Skip confirmation prompt. -fcli.agent.extensions.dry-run = Show what would be done without performing any changes. - -# fcli agent mcp -fcli.agent.mcp.usage.header = (PREVIEW) Manage fcli MCP server commands for AI assistants -fcli.agent.mcp.usage.description = Start fcli MCP servers for AI assistants, and generate HTTP MCP server config templates. -fcli.agent.mcp.start-stdio.usage.header = (PREVIEW) Start fcli MCP server on stdio for AI integration -fcli.agent.mcp.start-stdio.usage.description = Start the fcli MCP server over stdio. This command exposes fcli module commands and/or \ - imported action functions as MCP tools to AI clients.%n\ - %nTHREAD MODEL:%n\ - - Work threads (--work-threads): execute MCP tool calls. Each concurrent tool call occupies one thread for its\n\ - full duration. Size to the maximum number of tool calls the AI may invoke in parallel.%n\ - - Progress threads (--progress-threads): poll progress for long-running jobs at regular intervals. One thread\n\ - is consumed per active long-running job during each poll. The default of 4 is sufficient for most use cases.%n\ - - Async background threads (--async-bg-threads): run background async streaming jobs (e.g. run.fcli steps\n\ - with streaming output). Increase if actions make heavy use of async streaming.%n -fcli.agent.mcp.start-stdio.module = Fcli module to expose through this MCP server instance. -fcli.agent.mcp.start-stdio.import = Action YAML files to import. Exported functions are registered as MCP tools or resources based on function metadata. -fcli.agent.mcp.start-stdio.work-threads = Number of worker threads used to execute MCP tool jobs concurrently. Increase for higher parallelism if AI invokes multiple tools simultaneously. -fcli.agent.mcp.start-stdio.progress-threads = Number of threads used for updating and tracking job progress for long-running jobs. -fcli.agent.mcp.start-stdio.job-safe-return = Maximum time to wait synchronously for a job result before returning an in_progress placeholder. Specify duration like 25s, 2m, 1h. -fcli.agent.mcp.start-stdio.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). -fcli.agent.mcp.start-stdio.async-bg-threads = Number of background threads for running async streaming jobs. Default: 2. - -fcli.agent.mcp.start-http.usage.header = (PREVIEW) Start import-only HTTP fcli MCP server for AI integration -fcli.agent.mcp.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. Generate a sample config file with 'fcli agent mcp create-http-config --type ' and customize the generated YAML for your environment. The server listens for MCP POST requests on the /mcp endpoint. Each request must include the product-specific auth header as semicolon-separated key=value pairs; escape literal '\\', ';', or '=' characters as '\\\\', '\\;', or '\\='.\ - %n%nAUTH HEADERS (per HTTP request):\ - %n- SSC mode: X-AUTH-SSC: token=[;sc-sast-token=]\ - %n- FoD mode, user/PAT auth: X-AUTH-FOD: tenant=;user=;pat=\ - %n- FoD mode, client auth: X-AUTH-FOD: client-id=;client-secret=\ - %nExactly one FoD auth mode must be specified per request. -fcli.agent.mcp.start-http.config = Path to HTTP MCP YAML config file. Generate a template with 'fcli agent mcp create-http-config'. - -fcli.agent.mcp.create-http-config.usage.header = Generate a sample HTTP MCP server config file -fcli.agent.mcp.create-http-config.usage.description = Create a sample HTTP MCP config file for the selected product type. -fcli.agent.mcp.create-http-config.type = Product type for template generation: ssc or fod. -fcli.agent.mcp.create-http-config.config = Output path for the generated config file. Default: mcp-http-config.yaml. -fcli.agent.mcp.create-http-config.force = Overwrite an existing config file if it already exists. diff --git a/fcli-core/fcli-agent/build.gradle.kts b/fcli-core/fcli-ai-assist/build.gradle.kts similarity index 71% rename from fcli-core/fcli-agent/build.gradle.kts rename to fcli-core/fcli-ai-assist/build.gradle.kts index 8ba28ff4645..ac285635eb6 100644 --- a/fcli-core/fcli-agent/build.gradle.kts +++ b/fcli-core/fcli-ai-assist/build.gradle.kts @@ -3,6 +3,8 @@ plugins { id("fcli.module-conventions") } dependencies { val fodRef = project.findProperty("fcliFoDRef") as String val sscRef = project.findProperty("fcliSSCRef") as String + val toolRef = project.findProperty("fcliToolRef") as String implementation(project(fodRef)) implementation(project(sscRef)) + implementation(project(toolRef)) } diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/_main/cli/cmd/AiAssistCommands.java similarity index 63% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/_main/cli/cmd/AiAssistCommands.java index bb05d8700bb..ca2323a33a7 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/_main/cli/cmd/AgentCommands.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/_main/cli/cmd/AiAssistCommands.java @@ -10,12 +10,12 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent._main.cli.cmd; +package com.fortify.cli.ai_assist._main.cli.cmd; import static com.fortify.cli.common.cli.util.FcliModuleCategories.UTIL; -import com.fortify.cli.agent.extensions.cli.cmd.AgentExtensionsCommands; -import com.fortify.cli.agent.mcp.cli.cmd.AgentMCPCommands; +import com.fortify.cli.ai_assist.extensions.cli.cmd.AiAssistExtensionsCommands; +import com.fortify.cli.ai_assist.mcp.cli.cmd.AiAssistMCPCommands; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; import com.fortify.cli.common.cli.util.FcliModuleCategory; @@ -23,11 +23,12 @@ @FcliModuleCategory(UTIL) @Command( - name = "agent", - resourceBundle = "com.fortify.cli.agent.i18n.AgentMessages", + name = "ai-assist", + aliases = {"ai"}, + resourceBundle = "com.fortify.cli.ai_assist.i18n.AiAssistMessages", subcommands = { - AgentExtensionsCommands.class, - AgentMCPCommands.class + AiAssistExtensionsCommands.class, + AiAssistMCPCommands.class } ) -public class AgentCommands extends AbstractContainerCommand {} +public class AiAssistCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsCommands.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.java similarity index 60% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsCommands.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.java index fc92ba79930..87d3c3e1ae3 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsCommands.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.cli.cmd; +package com.fortify.cli.ai_assist.extensions.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; @@ -20,10 +20,12 @@ name = "extensions", aliases = {"ext"}, subcommands = { - AgentExtensionsInstallCommand.class, - AgentExtensionsUninstallCommand.class, - AgentExtensionsUpdateCommand.class, - AgentExtensionsStatusCommand.class + AiAssistExtensionsInstallCommand.class, + AiAssistExtensionsUninstallCommand.class, + AiAssistExtensionsUpdateCommand.class, + AiAssistExtensionsListInstalledCommand.class, + AiAssistExtensionsListVersionsCommand.class, + AiAssistExtensionsListAssistantsCommand.class } ) -public class AgentExtensionsCommands extends AbstractContainerCommand {} +public class AiAssistExtensionsCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsInstallCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsInstallCommand.java similarity index 56% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsInstallCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsInstallCommand.java index d9af0f36271..dbeb01921c5 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsInstallCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsInstallCommand.java @@ -10,15 +10,14 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.cli.cmd; +package com.fortify.cli.ai_assist.extensions.cli.cmd; import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsAssistantFilterMixin; -import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsSourceMixin; -import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller; -import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller.PolicyAction; +import com.fortify.cli.ai_assist.extensions.cli.mixin.AiAssistExtensionsAssistantFilterMixin; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -31,55 +30,52 @@ import picocli.CommandLine.Option; @Command(name = OutputHelperMixins.Install.CMD_NAME) -public class AgentExtensionsInstallCommand extends AbstractOutputCommand +public class AiAssistExtensionsInstallCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IActionCommandResultSupplier { @Mixin @Getter private OutputHelperMixins.Install outputHelper; - @Mixin private AgentExtensionsAssistantFilterMixin assistantFilter; - @Mixin private AgentExtensionsSourceMixin sourceMixin; + @Mixin private AiAssistExtensionsAssistantFilterMixin assistantFilter; + + @Option(names = {"-v", "--version"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.version", + defaultValue = "latest") + private String version; + + @Option(names = {"-s", "--source"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.source") + private String source; @Option(names = {"--dir"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.dir") + descriptionKey = "fcli.ai-assist.extensions.dir") private String customDir; @Option(names = {"--content-types"}, split = ",", paramLabel = "", - descriptionKey = "fcli.agent.extensions.content-types") + descriptionKey = "fcli.ai-assist.extensions.content-types") private Set contentTypeFilter; - @Option(names = {"--on-invalid-signature"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.on-invalid-signature", - defaultValue = "fail") - private PolicyAction onInvalidSignature; - - @Option(names = {"--on-unsigned"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.on-unsigned", - defaultValue = "fail") - private PolicyAction onUnsigned; - - @Option(names = {"--on-invalid-version"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.on-invalid-version", + @Option(names = {"--on-digest-mismatch"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.on-digest-mismatch", defaultValue = "fail") - private PolicyAction onInvalidVersion; + private DigestMismatchAction onDigestMismatch; @Option(names = {"-y", "--confirm"}, - descriptionKey = "fcli.agent.extensions.confirm") + descriptionKey = "fcli.ai-assist.extensions.confirm") private boolean confirm; @Option(names = {"--dry-run"}, - descriptionKey = "fcli.agent.extensions.dry-run") + descriptionKey = "fcli.ai-assist.extensions.dry-run") private boolean dryRun; @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AgentExtensionsInstaller.install( - sourceMixin.getSource(), + AiAssistExtensionsInstaller.install( + source, + version, assistantFilter.getAssistants(), assistantFilter.getExcludeAssistants(), contentTypeFilter, customDir, - onInvalidSignature, - onUnsigned, - onInvalidVersion, + onDigestMismatch, dryRun)); } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListAssistantsCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListAssistantsCommand.java new file mode 100644 index 00000000000..642fc8bad80 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListAssistantsCommand.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.cli.cmd; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "list-assistants") +public class AiAssistExtensionsListAssistantsCommand extends AbstractOutputCommand + implements IJsonNodeSupplier { + @Mixin @Getter private OutputHelperMixins.TableNoQuery outputHelper; + + @Option(names = {"--detect"}, + descriptionKey = "fcli.ai-assist.extensions.detect") + private boolean detect; + + @Override + public JsonNode getJsonNode() { + return JsonHelper.getObjectMapper().valueToTree( + AiAssistExtensionsInstaller.listAssistants(detect)); + } + + @Override + public boolean isSingular() { return false; } +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListInstalledCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListInstalledCommand.java new file mode 100644 index 00000000000..acaeb7de278 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListInstalledCommand.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.cli.cmd; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = "list-installed") +public class AiAssistExtensionsListInstalledCommand extends AbstractOutputCommand + implements IJsonNodeSupplier { + @Mixin @Getter private OutputHelperMixins.TableNoQuery outputHelper; + + @Override + public JsonNode getJsonNode() { + return JsonHelper.getObjectMapper().valueToTree( + AiAssistExtensionsInstaller.listInstalled()); + } + + @Override + public boolean isSingular() { return false; } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsStatusCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java similarity index 74% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsStatusCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java index e34c82ef37a..58fcf15008a 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsStatusCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java @@ -10,10 +10,10 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.cli.cmd; +package com.fortify.cli.ai_assist.extensions.cli.cmd; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -23,15 +23,15 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -@Command(name = OutputHelperMixins.Status.CMD_NAME) -public class AgentExtensionsStatusCommand extends AbstractOutputCommand +@Command(name = "list-versions") +public class AiAssistExtensionsListVersionsCommand extends AbstractOutputCommand implements IJsonNodeSupplier { - @Mixin @Getter private OutputHelperMixins.Status outputHelper; + @Mixin @Getter private OutputHelperMixins.TableNoQuery outputHelper; @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AgentExtensionsInstaller.status()); + AiAssistExtensionsInstaller.listVersions()); } @Override diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUninstallCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java similarity index 75% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUninstallCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java index ad041eef20d..a7bacad56aa 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUninstallCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java @@ -10,11 +10,11 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.cli.cmd; +package com.fortify.cli.ai_assist.extensions.cli.cmd; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsAssistantFilterMixin; -import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.cli.mixin.AiAssistExtensionsAssistantFilterMixin; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -27,23 +27,23 @@ import picocli.CommandLine.Option; @Command(name = OutputHelperMixins.Uninstall.CMD_NAME) -public class AgentExtensionsUninstallCommand extends AbstractOutputCommand +public class AiAssistExtensionsUninstallCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IActionCommandResultSupplier { @Mixin @Getter private OutputHelperMixins.Uninstall outputHelper; - @Mixin private AgentExtensionsAssistantFilterMixin assistantFilter; + @Mixin private AiAssistExtensionsAssistantFilterMixin assistantFilter; @Option(names = {"-y", "--confirm"}, - descriptionKey = "fcli.agent.extensions.confirm") + descriptionKey = "fcli.ai-assist.extensions.confirm") private boolean confirm; @Option(names = {"--dry-run"}, - descriptionKey = "fcli.agent.extensions.dry-run") + descriptionKey = "fcli.ai-assist.extensions.dry-run") private boolean dryRun; @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AgentExtensionsInstaller.uninstall( + AiAssistExtensionsInstaller.uninstall( assistantFilter.getAssistants(), assistantFilter.getExcludeAssistants(), dryRun)); diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUpdateCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUpdateCommand.java similarity index 56% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUpdateCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUpdateCommand.java index c8981e76408..603fba3f3b7 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/cmd/AgentExtensionsUpdateCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUpdateCommand.java @@ -10,15 +10,14 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.cli.cmd; +package com.fortify.cli.ai_assist.extensions.cli.cmd; import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsAssistantFilterMixin; -import com.fortify.cli.agent.extensions.cli.mixin.AgentExtensionsSourceMixin; -import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller; -import com.fortify.cli.agent.extensions.helper.AgentExtensionsInstaller.PolicyAction; +import com.fortify.cli.ai_assist.extensions.cli.mixin.AiAssistExtensionsAssistantFilterMixin; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -31,55 +30,52 @@ import picocli.CommandLine.Option; @Command(name = OutputHelperMixins.Update.CMD_NAME) -public class AgentExtensionsUpdateCommand extends AbstractOutputCommand +public class AiAssistExtensionsUpdateCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IActionCommandResultSupplier { @Mixin @Getter private OutputHelperMixins.Update outputHelper; - @Mixin private AgentExtensionsAssistantFilterMixin assistantFilter; - @Mixin private AgentExtensionsSourceMixin sourceMixin; + @Mixin private AiAssistExtensionsAssistantFilterMixin assistantFilter; + + @Option(names = {"-v", "--version"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.version", + defaultValue = "latest") + private String version; + + @Option(names = {"-s", "--source"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.source") + private String source; @Option(names = {"--dir"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.dir") + descriptionKey = "fcli.ai-assist.extensions.dir") private String customDir; @Option(names = {"--content-types"}, split = ",", paramLabel = "", - descriptionKey = "fcli.agent.extensions.content-types") + descriptionKey = "fcli.ai-assist.extensions.content-types") private Set contentTypeFilter; - @Option(names = {"--on-invalid-signature"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.on-invalid-signature", - defaultValue = "fail") - private PolicyAction onInvalidSignature; - - @Option(names = {"--on-unsigned"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.on-unsigned", - defaultValue = "fail") - private PolicyAction onUnsigned; - - @Option(names = {"--on-invalid-version"}, paramLabel = "", - descriptionKey = "fcli.agent.extensions.on-invalid-version", + @Option(names = {"--on-digest-mismatch"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.on-digest-mismatch", defaultValue = "fail") - private PolicyAction onInvalidVersion; + private DigestMismatchAction onDigestMismatch; @Option(names = {"-y", "--confirm"}, - descriptionKey = "fcli.agent.extensions.confirm") + descriptionKey = "fcli.ai-assist.extensions.confirm") private boolean confirm; @Option(names = {"--dry-run"}, - descriptionKey = "fcli.agent.extensions.dry-run") + descriptionKey = "fcli.ai-assist.extensions.dry-run") private boolean dryRun; @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AgentExtensionsInstaller.update( - sourceMixin.getSource(), + AiAssistExtensionsInstaller.update( + source, + version, assistantFilter.getAssistants(), assistantFilter.getExcludeAssistants(), contentTypeFilter, customDir, - onInvalidSignature, - onUnsigned, - onInvalidVersion, + onDigestMismatch, dryRun)); } diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsAssistantFilterMixin.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/mixin/AiAssistExtensionsAssistantFilterMixin.java similarity index 78% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsAssistantFilterMixin.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/mixin/AiAssistExtensionsAssistantFilterMixin.java index 579152805a0..f3b26bdbf5d 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/cli/mixin/AgentExtensionsAssistantFilterMixin.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/mixin/AiAssistExtensionsAssistantFilterMixin.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.cli.mixin; +package com.fortify.cli.ai_assist.extensions.cli.mixin; import java.util.Set; @@ -20,14 +20,14 @@ /** * Mixin providing --assistants and --exclude-assistants options. */ -public class AgentExtensionsAssistantFilterMixin { +public class AiAssistExtensionsAssistantFilterMixin { @Getter @Option(names = {"--assistants"}, split = ",", paramLabel = "", - descriptionKey = "fcli.agent.extensions.assistants") + descriptionKey = "fcli.ai-assist.extensions.assistants") private Set assistants; @Getter @Option(names = {"--exclude-assistants"}, split = ",", paramLabel = "", - descriptionKey = "fcli.agent.extensions.exclude-assistants") + descriptionKey = "fcli.ai-assist.extensions.exclude-assistants") private Set excludeAssistants; } diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsAssistantDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.java similarity index 84% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsAssistantDescriptor.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.java index 11fd370d447..501e8fc1b79 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsAssistantDescriptor.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.helper; +package com.fortify.cli.ai_assist.extensions.helper; import java.util.List; @@ -24,10 +24,10 @@ * Per-assistant configuration from extensions-distribution.yaml. */ @Reflectable @NoArgsConstructor @Data -public class AgentExtensionsAssistantDescriptor { +public class AiAssistExtensionsAssistantDescriptor { @JsonProperty("display-name") private String displayName; @JsonProperty("if") private Object ifCondition; - private List targets; + private List targets; } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantOutputDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantOutputDescriptor.java new file mode 100644 index 00000000000..970475b9b04 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantOutputDescriptor.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Output record for the list-assistants command. + */ +@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data +public class AiAssistExtensionsAssistantOutputDescriptor { + private String id; + private String name; + private String[] contentTypes; + private String contentTypesString; + private String detected; + private boolean installed; + private String installedVersion; +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java new file mode 100644 index 00000000000..48fd83d84e5 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java @@ -0,0 +1,188 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Evaluates declarative conditions from the extensions-distribution.yaml descriptor. + * Supports simple conditions (dir-exists, command-exists) and + * logical operators (any-of, all-of, not). + */ +public final class AiAssistExtensionsConditionEvaluator { + private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsConditionEvaluator.class); + + public AiAssistExtensionsConditionEvaluator() {} + + /** + * Evaluate a condition object (may be a map with a single condition or operator). + */ + @SuppressWarnings("unchecked") + public boolean evaluate(Object condition) { + if (condition == null) { return true; } + if (condition instanceof Map map) { + return evaluateMap((Map) map); + } + LOG.warn("Unknown condition type: {}", condition.getClass().getName()); + return false; + } + + @SuppressWarnings("unchecked") + private boolean evaluateMap(Map map) { + for (var entry : map.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + switch (key) { + case "dir-exists": + return evaluateDirExists(value); + case "glob-exists": + return evaluateGlobExists(value); + case "command-exists": + return evaluateCommandExists((String) value); + case "command-succeeds": + return evaluateCommandSucceeds((String) value); + case "any-of": + return evaluateAnyOf((java.util.List) value); + case "all-of": + return evaluateAllOf((java.util.List) value); + case "not": + return !evaluate(value); + default: + LOG.warn("Unknown condition type '{}', treating as false", key); + return false; + } + } + return true; + } + + private boolean evaluateDirExists(Object value) { + if (value instanceof String s) { + var resolved = AiAssistExtensionsPathResolver.resolvePath(s); + return resolved != null && Files.isDirectory(resolved); + } else if (value instanceof Map) { + var resolved = AiAssistExtensionsPathResolver.resolve(value); + return resolved != null && Files.isDirectory(resolved); + } + return false; + } + + /** + * Check if a glob pattern (with tilde/env-var expansion) matches at least one + * existing directory. Useful for patterns like {@code ~/.vscode/extensions/github.copilot-*}. + * Value may be a plain string or a platform-specific map. + */ + @SuppressWarnings("unchecked") + private boolean evaluateGlobExists(Object value) { + String pattern; + if (value instanceof String s) { + pattern = s; + } else if (value instanceof Map map) { + var platformKey = SystemUtils.IS_OS_WINDOWS ? "windows" + : SystemUtils.IS_OS_MAC ? "darwin" : "linux"; + pattern = (String) ((Map) map).get(platformKey); + } else { + return false; + } + if (pattern == null) { return false; } + if (pattern.startsWith("~/")) { + pattern = System.getProperty("user.home") + pattern.substring(1); + } + // Split into parent dir (no globs) and the glob tail + // Walk segments to find where the first glob char appears + var segments = pattern.split("/"); + var parentBuilder = new StringBuilder(); + int globStart = -1; + for (int i = 0; i < segments.length; i++) { + if (segments[i].contains("*") || segments[i].contains("?") || segments[i].contains("[")) { + globStart = i; + break; + } + if (i > 0) { parentBuilder.append('/'); } + parentBuilder.append(segments[i]); + } + if (globStart < 0) { + // No glob chars — just check directory existence + return Files.isDirectory(Path.of(pattern)); + } + var parentPath = Path.of(parentBuilder.toString()); + if (!Files.isDirectory(parentPath)) { return false; } + // Build glob pattern from the remaining segments + var globTail = String.join("/", java.util.Arrays.copyOfRange(segments, globStart, segments.length)); + var matcher = FileSystems.getDefault().getPathMatcher("glob:" + globTail); + try (var stream = Files.walk(parentPath, segments.length - globStart)) { + return stream.anyMatch(p -> matcher.matches(parentPath.relativize(p))); + } catch (IOException e) { + LOG.debug("Error evaluating glob '{}': {}", value, e.getMessage()); + return false; + } + } + + private boolean evaluateCommandExists(String command) { + if (StringUtils.isBlank(command)) { return false; } + var cmd = SystemUtils.IS_OS_WINDOWS + ? new String[]{"where", command} + : new String[]{"which", command}; + return runProcessSucceeds(cmd, command); + } + + /** + * Run an arbitrary command line and check for exit code 0. + * The value is split on whitespace. A 5-second timeout prevents hangs. + */ + private boolean evaluateCommandSucceeds(String commandLine) { + if (StringUtils.isBlank(commandLine)) { return false; } + var parts = commandLine.trim().split("\\s+"); + return runProcessSucceeds(parts, commandLine); + } + + private boolean runProcessSucceeds(String[] cmd, String label) { + try { + var pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + var process = pb.start(); + // Drain output to prevent blocking + process.getInputStream().transferTo(java.io.OutputStream.nullOutputStream()); + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + LOG.debug("Command timed out: {}", label); + return false; + } + return process.exitValue() == 0; + } catch (IOException | InterruptedException e) { + LOG.debug("Error running command '{}': {}", label, e.getMessage()); + return false; + } + } + + private boolean evaluateAnyOf(List conditions) { + if (conditions == null) { return false; } + return conditions.stream().anyMatch(this::evaluate); + } + + private boolean evaluateAllOf(List conditions) { + if (conditions == null) { return false; } + return conditions.stream().allMatch(this::evaluate); + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsDistributionDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentManifestDescriptor.java similarity index 65% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsDistributionDescriptor.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentManifestDescriptor.java index c411f38a0aa..018c6f59f0b 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsDistributionDescriptor.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentManifestDescriptor.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.helper; +package com.fortify.cli.ai_assist.extensions.helper; import java.util.Map; @@ -21,14 +21,12 @@ import lombok.NoArgsConstructor; /** - * Root descriptor for extensions-distribution.yaml. + * Descriptor for content-manifest.yaml (embedded in the extensions zip). + * Describes what content the archive contains and how to discover entries. */ @Reflectable @NoArgsConstructor @Data -public class AgentExtensionsDistributionDescriptor { - @JsonProperty("schemaVersion") - private String schemaVersion; +public class AiAssistExtensionsContentManifestDescriptor { + private int schemaVersion; @JsonProperty("content-types") - private Map contentTypes; - @JsonProperty("assistants") - private Map assistants; + private Map contentTypes; } diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsContentTypeDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentTypeDescriptor.java similarity index 75% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsContentTypeDescriptor.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentTypeDescriptor.java index d064bb17a73..1e9b109cedb 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsContentTypeDescriptor.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentTypeDescriptor.java @@ -10,7 +10,9 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.helper; +package com.fortify.cli.ai_assist.extensions.helper; + +import java.util.Map; import com.fasterxml.jackson.annotation.JsonProperty; import com.formkiq.graalvm.annotations.Reflectable; @@ -19,11 +21,11 @@ import lombok.NoArgsConstructor; /** - * Content type configuration from extensions-distribution.yaml. + * Content type configuration from content-manifest.yaml. * Defines how entries are discovered within the source archive. */ @Reflectable @NoArgsConstructor @Data -public class AgentExtensionsContentTypeDescriptor { +public class AiAssistExtensionsContentTypeDescriptor { @JsonProperty("source-dir") private String sourceDir; private String discover; @@ -31,4 +33,6 @@ public class AgentExtensionsContentTypeDescriptor { private String entryMarker; @JsonProperty("file-pattern") private String filePattern; + /** Named entries for explicit discovery mode. Key = logical name, value = path in archive. */ + private Map entries; } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsDistributionDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsDistributionDescriptor.java new file mode 100644 index 00000000000..ad9786fbd6d --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsDistributionDescriptor.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.util.Map; + +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Distribution descriptor for agent extensions (from tool-definitions zip). + * Contains only the assistant mapping (detection + target directories). + * Content type definitions come from the content-manifest.yaml in the extensions zip. + */ +@Reflectable @NoArgsConstructor @Data +public class AiAssistExtensionsDistributionDescriptor { + private int schemaVersion; + private Map assistants; +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanContext.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanContext.java new file mode 100644 index 00000000000..2d9e82b25b3 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanContext.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Tracks the state of an install/update plan for directory-overlap deduplication. + * When multiple assistants share a target directory, only the first one installs; + * subsequent assistants reuse the existing files (EXISTING). + */ +public final class AiAssistExtensionsInstallPlanContext { + /** + * Set of resolved target directory + content type combinations that have + * already been covered (files installed or planned) by a previous assistant. + */ + private final Set coveredDirs = new HashSet<>(); + + /** + * Mark a target directory as covered (files have been installed there). + */ + public void markCovered(Path resolvedTargetDir, String contentType) { + coveredDirs.add(toCoverageKey(resolvedTargetDir, contentType)); + } + + /** + * Check if a target directory is already covered for a given content type. + */ + public boolean isCovered(Path resolvedTargetDir, String contentType) { + return coveredDirs.contains(toCoverageKey(resolvedTargetDir, contentType)); + } + + /** + * Find the first covered directory from a list of candidates. + * @return the first covered path, or null if none are covered + */ + public Path findCoveredDir(List candidates, String contentType) { + return candidates.stream() + .filter(p -> isCovered(p, contentType)) + .findFirst() + .orElse(null); + } + + private static Path toCoverageKey(Path dir, String contentType) { + return dir.resolve("__ct__" + contentType); + } +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java new file mode 100644 index 00000000000..e57472f89ba --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java @@ -0,0 +1,737 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsStateDescriptor.FileEntry; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.FcliDataHelper; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionRootDescriptor; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionVersionDescriptor; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionsHelper; + +/** + * Core install/update/uninstall/list logic for AI assistant extensions. + * Output is grouped by (assistant, contentType, targetDir). + */ +public final class AiAssistExtensionsInstaller { + private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsInstaller.class); + private static final Path STATE_BASE_PATH = Path.of("state", "ai-assist", "extensions"); + + private AiAssistExtensionsInstaller() {} + + // ──────────────────────────── Version resolution ──────────────────────────── + + public static ToolDefinitionRootDescriptor getToolDefinitions() { + return ToolDefinitionsHelper.getToolDefinitionRootDescriptor( + AiAssistExtensionsSourceHandler.TOOL_NAME); + } + + public static ToolDefinitionVersionDescriptor resolveVersion(String version) { + return getToolDefinitions().getVersionOrDefault(version); + } + + // ──────────────────────────── Install ──────────────────────────── + + public static List install( + String source, String version, + Set assistantFilter, Set excludeAssistants, + Set contentTypeFilter, String customDir, + DigestMismatchAction onDigestMismatch, boolean dryRun) { + + try (var sourceHandler = resolveSource(source, version, onDigestMismatch)) { + var contentManifest = sourceHandler.readContentManifest(); + var distribution = AiAssistExtensionsSourceHandler + .readDistributionDescriptor(source == null); + var conditionEvaluator = new AiAssistExtensionsConditionEvaluator(); + var planContext = new AiAssistExtensionsInstallPlanContext(); + + var assistants = detectAssistants(distribution, conditionEvaluator, + assistantFilter, excludeAssistants); + var plan = buildInstallPlan(contentManifest, distribution, assistants, + sourceHandler, planContext, contentTypeFilter, customDir, + sourceHandler.getVersion()); + + if (!dryRun) { + executePlan(plan, sourceHandler); + } + return toOutputDescriptors(plan); + } + } + + // ──────────────────────────── Update ──────────────────────────── + + public static List update( + String source, String version, + Set assistantFilter, Set excludeAssistants, + Set contentTypeFilter, String customDir, + DigestMismatchAction onDigestMismatch, boolean dryRun) { + + try (var sourceHandler = resolveSource(source, version, onDigestMismatch)) { + var contentManifest = sourceHandler.readContentManifest(); + var distribution = AiAssistExtensionsSourceHandler + .readDistributionDescriptor(source == null); + var conditionEvaluator = new AiAssistExtensionsConditionEvaluator(); + var planContext = new AiAssistExtensionsInstallPlanContext(); + + var assistants = detectAssistants(distribution, conditionEvaluator, + assistantFilter, excludeAssistants); + var plan = buildUpdatePlan(contentManifest, distribution, assistants, + sourceHandler, planContext, contentTypeFilter, customDir, + sourceHandler.getVersion()); + + if (!dryRun) { + executeUpdatePlan(plan, sourceHandler); + } + return toOutputDescriptors(plan); + } + } + + // ──────────────────────────── Uninstall ──────────────────────────── + + public static List uninstall( + Set assistantFilter, Set excludeAssistants, + boolean dryRun) { + var stateEntries = loadAllStateDescriptors(); + var results = new ArrayList(); + + for (var state : stateEntries) { + if (!matchesFilter(state.getAssistantId(), assistantFilter, excludeAssistants)) { + continue; + } + if (!dryRun) { + for (var file : state.getFiles()) { + deleteTargetFile(Path.of(file.getTarget())); + } + deleteStateDescriptor(state.getAssistantId(), state.getContentType()); + } + results.add(stateToOutput(state, "REMOVED")); + } + if (!dryRun) { cleanEmptyStateDirs(); } + return results; + } + + // ──────────────────────────── List installed ──────────────────────────── + + public static List listInstalled() { + return loadAllStateDescriptors().stream() + .map(s -> stateToOutput(s, null)) + .toList(); + } + + // ──────────────────────────── List versions ──────────────────────────── + + public static List listVersions() { + var defs = getToolDefinitions(); + var result = new ArrayList(); + for (var v : defs.getVersions()) { + result.add(AiAssistExtensionsVersionOutputDescriptor.builder() + .version(v.getVersion()) + .aliases(v.getAliases() != null ? String.join(", ", v.getAliases()) : "") + .stable(v.isStable()) + .build()); + } + return result; + } + + // ──────────────────────────── List assistants ──────────────────────────── + + public static List listAssistants(boolean detect) { + var distribution = AiAssistExtensionsSourceHandler.readDistributionDescriptor(true); + if (distribution.getAssistants() == null) { return Collections.emptyList(); } + + var conditionEvaluator = detect ? new AiAssistExtensionsConditionEvaluator() : null; + var installedState = loadAllStateDescriptors(); + var installedByAssistant = installedState.stream() + .collect(Collectors.groupingBy(AiAssistExtensionsStateDescriptor::getAssistantId)); + + var result = new ArrayList(); + for (var entry : distribution.getAssistants().entrySet()) { + var id = entry.getKey(); + var assistant = entry.getValue(); + var contentTypes = assistant.getTargets() != null + ? assistant.getTargets().stream() + .map(AiAssistExtensionsTargetDescriptor::getContentType) + .toArray(String[]::new) + : new String[0]; + + String detected; + if (conditionEvaluator != null) { + detected = String.valueOf(conditionEvaluator.evaluate(assistant.getIfCondition())); + } else { + detected = "N/A"; + } + + var assistantStates = installedByAssistant.getOrDefault(id, Collections.emptyList()); + var installed = !assistantStates.isEmpty(); + var installedVersion = assistantStates.stream() + .map(AiAssistExtensionsStateDescriptor::getSourceVersion) + .findFirst().orElse(null); + + result.add(AiAssistExtensionsAssistantOutputDescriptor.builder() + .id(id) + .name(assistant.getDisplayName()) + .contentTypes(contentTypes) + .contentTypesString(String.join(", ", contentTypes)) + .detected(detected) + .installed(installed) + .installedVersion(installedVersion) + .build()); + } + return result; + } + + // ──────────────────────────── Source resolution ──────────────────────────── + + private static AiAssistExtensionsSourceHandler resolveSource( + String source, String version, DigestMismatchAction onDigestMismatch) { + if (source != null) { + return AiAssistExtensionsSourceHandler.fromLocalSource(source); + } + var versionDesc = resolveVersion(version); + return AiAssistExtensionsSourceHandler.fromToolDefinitions(versionDesc, onDigestMismatch); + } + + // ──────────────────────────── Assistant detection ──────────────────────────── + + private static Map detectAssistants( + AiAssistExtensionsDistributionDescriptor distribution, + AiAssistExtensionsConditionEvaluator evaluator, + Set assistantFilter, Set excludeAssistants) { + var result = new LinkedHashMap(); + if (distribution.getAssistants() == null) { return result; } + + for (var entry : distribution.getAssistants().entrySet()) { + var id = entry.getKey(); + var assistant = entry.getValue(); + if (!matchesFilter(id, assistantFilter, excludeAssistants)) { continue; } + boolean explicitlySelected = assistantFilter != null && !assistantFilter.isEmpty(); + if (explicitlySelected || evaluator.evaluate(assistant.getIfCondition())) { + result.put(id, assistant); + } + } + return result; + } + + private static boolean matchesFilter(String id, Set include, Set exclude) { + if (include != null && !include.isEmpty() && !include.contains(id)) { return false; } + if (exclude != null && exclude.contains(id)) { return false; } + return true; + } + + // ──────────────────────────── Internal plan entry ──────────────────────────── + + /** + * Internal per-file plan entry used during plan construction. + * Aggregated into grouped output descriptors after planning. + */ + private record PlanEntry( + String assistant, String assistantId, String contentType, + String targetDir, String sourceFile, String targetPath, + String sourceVersion, String action) {} + + // ──────────────────────────── Install plan ──────────────────────────── + + private static List buildInstallPlan( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsDistributionDescriptor distribution, + Map assistants, + AiAssistExtensionsSourceHandler sourceHandler, + AiAssistExtensionsInstallPlanContext planContext, + Set contentTypeFilter, String customDir, + String sourceVersion) { + var plan = new ArrayList(); + + for (var entry : assistants.entrySet()) { + var assistantId = entry.getKey(); + var assistant = entry.getValue(); + if (assistant.getTargets() == null) { continue; } + + for (var target : assistant.getTargets()) { + var contentType = target.getContentType(); + if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } + + var resolvedDirs = customDir != null + ? List.of(Path.of(customDir).toAbsolutePath().normalize()) + : AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); + if (resolvedDirs.isEmpty()) { continue; } + + var coveredDir = planContext.findCoveredDir(resolvedDirs, contentType); + boolean isExisting = coveredDir != null; + var resolvedDir = isExisting ? coveredDir : resolvedDirs.get(0); + + if (!isExisting) { + planContext.markCovered(resolvedDir, contentType); + } + + var sourceFiles = discoverSourceFiles(contentManifest, target, sourceHandler); + for (var sourceFile : sourceFiles) { + var targetRelPath = getTargetRelativePath(contentManifest, target, sourceFile); + var targetPath = resolvedDir.resolve(targetRelPath).toString(); + plan.add(new PlanEntry( + assistant.getDisplayName(), assistantId, contentType, + resolvedDir.toString(), sourceFile, targetPath, + sourceVersion, isExisting ? "EXISTING" : "INSTALLED")); + } + } + } + return plan; + } + + // ──────────────────────────── Update plan ──────────────────────────── + + private static List buildUpdatePlan( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsDistributionDescriptor distribution, + Map assistants, + AiAssistExtensionsSourceHandler sourceHandler, + AiAssistExtensionsInstallPlanContext planContext, + Set contentTypeFilter, String customDir, + String sourceVersion) { + var installPlan = buildInstallPlan(contentManifest, distribution, assistants, + sourceHandler, planContext, contentTypeFilter, customDir, sourceVersion); + + var existingState = loadAllStateDescriptors(); + // Build lookup: "assistantId:contentType:sourceFile" → target path + var existingByKey = new LinkedHashMap(); + for (var state : existingState) { + for (var file : state.getFiles()) { + existingByKey.put(state.getAssistantId() + ":" + state.getContentType() + + ":" + file.getSource(), file.getTarget()); + } + } + + var plan = new ArrayList(); + var handledKeys = new java.util.HashSet(); + + for (var entry : installPlan) { + var key = entry.assistantId() + ":" + entry.contentType() + + ":" + entry.sourceFile(); + handledKeys.add(key); + + if ("EXISTING".equals(entry.action())) { + plan.add(entry); + continue; + } + + var existingTarget = existingByKey.get(key); + if (existingTarget == null) { + plan.add(new PlanEntry(entry.assistant(), entry.assistantId(), + entry.contentType(), entry.targetDir(), entry.sourceFile(), + entry.targetPath(), sourceVersion, "INSTALLED")); + } else if (hasFileChanged(sourceHandler, entry.sourceFile(), Path.of(existingTarget))) { + plan.add(new PlanEntry(entry.assistant(), entry.assistantId(), + entry.contentType(), entry.targetDir(), entry.sourceFile(), + entry.targetPath(), sourceVersion, "UPDATED")); + } else { + plan.add(new PlanEntry(entry.assistant(), entry.assistantId(), + entry.contentType(), entry.targetDir(), entry.sourceFile(), + entry.targetPath(), sourceVersion, "UNCHANGED")); + } + } + + // Files in state but not in source → REMOVED + for (var state : existingState) { + if (!matchesFilter(state.getAssistantId(), + assistants.isEmpty() ? null : assistants.keySet(), null)) { + continue; + } + for (var file : state.getFiles()) { + var key = state.getAssistantId() + ":" + state.getContentType() + + ":" + file.getSource(); + if (!handledKeys.contains(key)) { + plan.add(new PlanEntry(state.getAssistant(), state.getAssistantId(), + state.getContentType(), state.getTargetDir(), file.getSource(), + file.getTarget(), sourceVersion, "REMOVED")); + } + } + } + return plan; + } + + // ──────────────────────────── Plan → grouped output ──────────────────────────── + + /** + * Aggregate per-file plan entries into grouped output descriptors, + * one per (assistantId, contentType, targetDir, action). + */ + private static List toOutputDescriptors(List plan) { + // Group by (assistantId, contentType, targetDir, action) + var groups = plan.stream().collect(Collectors.groupingBy( + e -> e.assistantId() + "\0" + e.contentType() + "\0" + e.targetDir() + "\0" + e.action(), + LinkedHashMap::new, Collectors.toList())); + + var result = new ArrayList(); + for (var entries : groups.values()) { + var first = entries.get(0); + var files = entries.stream() + .map(e -> targetRelativePath(e.targetDir(), e.targetPath())) + .toArray(String[]::new); + result.add(AiAssistExtensionsOutputDescriptor.builder() + .assistant(first.assistant()) + .assistantId(first.assistantId()) + .contentType(first.contentType()) + .targetDir(first.targetDir()) + .fileCount(files.length) + .sourceVersion(first.sourceVersion()) + .files(files) + .filesString(String.join(", ", files)) + .actionResult(first.action()) + .build()); + } + return result; + } + + private static String targetRelativePath(String targetDir, String targetPath) { + var dirPath = Path.of(targetDir); + var fullPath = Path.of(targetPath); + if (fullPath.startsWith(dirPath)) { + return dirPath.relativize(fullPath).toString(); + } + return targetPath; + } + + // ──────────────────────────── State → output ──────────────────────────── + + private static AiAssistExtensionsOutputDescriptor stateToOutput( + AiAssistExtensionsStateDescriptor state, String action) { + var files = state.getFiles() != null + ? state.getFiles().stream() + .map(f -> targetRelativePath(state.getTargetDir(), f.getTarget())) + .toArray(String[]::new) + : new String[0]; + return AiAssistExtensionsOutputDescriptor.builder() + .assistant(state.getAssistant()) + .assistantId(state.getAssistantId()) + .contentType(state.getContentType()) + .targetDir(state.getTargetDir()) + .fileCount(files.length) + .sourceVersion(state.getSourceVersion()) + .files(files) + .filesString(String.join(", ", files)) + .actionResult(action) + .build(); + } + + // ──────────────────────────── Content discovery ──────────────────────────── + + private static List discoverSourceFiles( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsTargetDescriptor target, + AiAssistExtensionsSourceHandler sourceHandler) { + var contentType = target.getContentType(); + var ctDesc = contentManifest.getContentTypes() != null + ? contentManifest.getContentTypes().get(contentType) : null; + if (ctDesc == null) { return Collections.emptyList(); } + + var discoverMode = ctDesc.getDiscover(); + if ("explicit".equals(discoverMode)) { + return discoverExplicitEntries(ctDesc, target, sourceHandler); + } + + var sourceDir = ctDesc.getSourceDir(); + if (sourceDir == null || !sourceHandler.exists(sourceDir)) { + return Collections.emptyList(); + } + + if ("directory".equals(discoverMode)) { + return discoverDirectoryEntries(sourceDir, ctDesc.getEntryMarker(), sourceHandler); + } else if ("files".equals(discoverMode)) { + return discoverFileEntries(sourceDir, ctDesc.getFilePattern(), sourceHandler); + } + return Collections.emptyList(); + } + + private static List discoverDirectoryEntries( + String sourceDir, String entryMarker, + AiAssistExtensionsSourceHandler sourceHandler) { + var result = new ArrayList(); + sourceHandler.listDirs(sourceDir).forEach(dir -> { + if (entryMarker != null) { + var markerPath = dir.resolve(entryMarker); + if (!sourceHandler.exists(markerPath.toString())) { return; } + } + sourceHandler.listFiles(dir.toString()).forEach(f -> { + var relative = sourceHandler.getExtractedDir().relativize( + sourceHandler.getExtractedDir().resolve(f)); + result.add(relative.toString()); + }); + }); + return result; + } + + private static List discoverFileEntries( + String sourceDir, String filePattern, + AiAssistExtensionsSourceHandler sourceHandler) { + var result = new ArrayList(); + var globPattern = filePattern != null ? filePattern : "*"; + sourceHandler.listFiles(sourceDir).forEach(f -> { + if (matchesGlob(f.getFileName().toString(), globPattern)) { + result.add(f.toString()); + } + }); + return result; + } + + private static List discoverExplicitEntries( + AiAssistExtensionsContentTypeDescriptor ctDesc, + AiAssistExtensionsTargetDescriptor target, + AiAssistExtensionsSourceHandler sourceHandler) { + if (target.getSourceEntries() == null) { return Collections.emptyList(); } + var entriesMap = ctDesc.getEntries(); + var result = new ArrayList(); + for (var entryName : target.getSourceEntries()) { + var entryPath = entriesMap != null ? entriesMap.get(entryName) : entryName; + if (entryPath == null) { entryPath = entryName; } + if (sourceHandler.exists(entryPath)) { + var resolvedPath = sourceHandler.getExtractedDir().resolve(entryPath); + if (Files.isDirectory(resolvedPath)) { + sourceHandler.listFiles(entryPath).forEach(f -> result.add(f.toString())); + } else { + result.add(entryPath); + } + } + } + return result; + } + + private static String getTargetRelativePath( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsTargetDescriptor target, String sourceFile) { + var contentType = target.getContentType(); + var ctDesc = contentManifest.getContentTypes() != null + ? contentManifest.getContentTypes().get(contentType) : null; + + if (ctDesc == null) { return Path.of(sourceFile).getFileName().toString(); } + + var discoverMode = ctDesc.getDiscover(); + if ("explicit".equals(discoverMode)) { + var entriesMap = ctDesc.getEntries(); + if (entriesMap != null && target.getSourceEntries() != null) { + for (var entryName : target.getSourceEntries()) { + var entryPath = entriesMap.getOrDefault(entryName, entryName); + if (sourceFile.startsWith(entryPath + "/")) { + return sourceFile.substring(entryPath.length() + 1); + } else if (sourceFile.equals(entryPath)) { + return Path.of(sourceFile).getFileName().toString(); + } + } + } + return Path.of(sourceFile).getFileName().toString(); + } + + if (ctDesc.getSourceDir() != null && sourceFile.startsWith(ctDesc.getSourceDir() + "/")) { + return sourceFile.substring(ctDesc.getSourceDir().length() + 1); + } + return sourceFile; + } + + private static boolean matchesGlob(String filename, String glob) { + var regex = glob.replace(".", "\\.").replace("*", ".*"); + return filename.matches(regex); + } + + // ──────────────────────────── Plan execution ──────────────────────────── + + private static void executePlan(List plan, + AiAssistExtensionsSourceHandler sourceHandler) { + for (var entry : plan) { + if ("INSTALLED".equals(entry.action())) { + installFile(sourceHandler, entry); + } + } + savePlanState(plan); + } + + private static void executeUpdatePlan(List plan, + AiAssistExtensionsSourceHandler sourceHandler) { + for (var entry : plan) { + switch (entry.action()) { + case "INSTALLED", "UPDATED" -> installFile(sourceHandler, entry); + case "REMOVED" -> deleteTargetFile(Path.of(entry.targetPath())); + } + } + savePlanState(plan); + cleanEmptyStateDirs(); + } + + private static void installFile(AiAssistExtensionsSourceHandler sourceHandler, PlanEntry entry) { + var targetPath = Path.of(entry.targetPath()); + var sourceBytes = sourceHandler.readFileBytes(entry.sourceFile()); + if (sourceBytes == null) { + throw new FcliSimpleException("Source file not found: " + entry.sourceFile()); + } + try { + Files.createDirectories(targetPath.getParent()); + Files.write(targetPath, sourceBytes); + } catch (IOException e) { + throw new FcliTechnicalException("Error installing file: " + targetPath, e); + } + } + + // ──────────────────────────── State management ──────────────────────────── + + /** + * Save grouped state descriptors from a plan. + * Groups plan entries by (assistantId, contentType) and writes one state file each. + * Entries with action REMOVED cause their state file to be deleted. + */ + private static void savePlanState(List plan) { + // Group by (assistantId, contentType) + var groups = plan.stream().collect(Collectors.groupingBy( + e -> e.assistantId() + "\0" + e.contentType(), + LinkedHashMap::new, Collectors.toList())); + + for (var groupEntries : groups.values()) { + var first = groupEntries.get(0); + // Collect non-removed files + var files = groupEntries.stream() + .filter(e -> !"REMOVED".equals(e.action()) && !"EXISTING".equals(e.action())) + .map(e -> FileEntry.builder() + .source(e.sourceFile()) + .target(e.targetPath()) + .build()) + .toList(); + + if (files.isEmpty()) { + // All removed or all existing — delete state + deleteStateDescriptor(first.assistantId(), first.contentType()); + } else { + var state = AiAssistExtensionsStateDescriptor.builder() + .assistant(first.assistant()) + .assistantId(first.assistantId()) + .contentType(first.contentType()) + .targetDir(first.targetDir()) + .sourceVersion(first.sourceVersion()) + .timestamp(Instant.now().toString()) + .files(files) + .build(); + var relativePath = STATE_BASE_PATH + .resolve(first.assistantId()) + .resolve(first.contentType() + ".json"); + FcliDataHelper.saveFile(relativePath, state, true); + } + } + } + + private static void deleteStateDescriptor(String assistantId, String contentType) { + var relativePath = STATE_BASE_PATH + .resolve(assistantId) + .resolve(contentType + ".json"); + FcliDataHelper.deleteFile(relativePath, false); + } + + private static void deleteTargetFile(Path targetPath) { + try { + Files.deleteIfExists(targetPath); + var parent = targetPath.getParent(); + while (parent != null && Files.isDirectory(parent)) { + try (var stream = Files.list(parent)) { + if (stream.findAny().isEmpty()) { + Files.delete(parent); + parent = parent.getParent(); + } else { + break; + } + } + } + } catch (IOException e) { + LOG.warn("Error deleting file: {}", targetPath, e); + } + } + + static List loadAllStateDescriptors() { + var result = new ArrayList(); + var basePath = FcliDataHelper.getFcliHomePath().resolve(STATE_BASE_PATH); + if (!Files.isDirectory(basePath)) { return result; } + + try { + Files.walkFileTree(basePath, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (file.toString().endsWith(".json")) { + try { + var content = Files.readString(file); + var desc = JsonHelper.getObjectMapper() + .readValue(content, AiAssistExtensionsStateDescriptor.class); + result.add(desc); + } catch (IOException e) { + LOG.warn("Error reading state file: {}", file, e); + } + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + LOG.warn("Error walking state directory: {}", basePath, e); + } + return result; + } + + private static void cleanEmptyStateDirs() { + var basePath = FcliDataHelper.getFcliHomePath().resolve(STATE_BASE_PATH); + if (!Files.isDirectory(basePath)) { return; } + try { + Files.walkFileTree(basePath, new SimpleFileVisitor() { + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + if (!dir.equals(basePath)) { + try (var stream = Files.list(dir)) { + if (stream.findAny().isEmpty()) { Files.delete(dir); } + } + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + LOG.debug("Error cleaning empty state dirs", e); + } + } + + private static boolean hasFileChanged( + AiAssistExtensionsSourceHandler sourceHandler, String sourceFile, Path targetPath) { + if (!Files.isRegularFile(targetPath)) { return true; } + try { + var sourceBytes = sourceHandler.readFileBytes(sourceFile); + var targetBytes = Files.readAllBytes(targetPath); + return sourceBytes == null || !Arrays.equals(sourceBytes, targetBytes); + } catch (IOException e) { + return true; + } + } + + private static boolean matchesContentTypeFilter(String contentType, Set filter) { + return filter == null || filter.isEmpty() || filter.contains(contentType); + } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsOutputDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsOutputDescriptor.java similarity index 66% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsOutputDescriptor.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsOutputDescriptor.java index c34e4e8bf82..0218d6543cd 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsOutputDescriptor.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsOutputDescriptor.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.helper; +package com.fortify.cli.ai_assist.extensions.helper; import com.fasterxml.jackson.annotation.JsonProperty; import com.formkiq.graalvm.annotations.Reflectable; @@ -22,18 +22,21 @@ import lombok.NoArgsConstructor; /** - * Output record produced by install/update/uninstall/status commands. - * When serialized, the __action__ field is included in the JSON output. + * Output record produced by install/update/uninstall/list-installed commands. + * One row per (assistant, contentType, targetDir) grouping. */ -@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data -public class AgentExtensionsOutputDescriptor { +@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) @Data +public class AiAssistExtensionsOutputDescriptor { private String assistant; private String assistantId; - private String file; private String contentType; private String targetDir; - private String targetPath; + private int fileCount; private String sourceVersion; + /** Array of individual file paths (relative to targetDir). */ + private String[] files; + /** Concatenated file paths for display. */ + private String filesString; @JsonProperty(IActionCommandResultSupplier.actionFieldName) private String actionResult; } diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsPathResolver.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java similarity index 79% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsPathResolver.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java index 72d12ead770..42a7c728952 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsPathResolver.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java @@ -10,10 +10,13 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.helper; +package com.fortify.cli.ai_assist.extensions.helper; import java.nio.file.Path; +import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.SystemUtils; @@ -24,8 +27,21 @@ * Resolves target-dir values from the descriptor: tilde expansion, * ${VAR} environment variable substitution, and platform map selection. */ -public final class AgentExtensionsPathResolver { - private AgentExtensionsPathResolver() {} +public final class AiAssistExtensionsPathResolver { + private AiAssistExtensionsPathResolver() {} + + /** + * Resolve a list of target-dir entries to a list of resolved paths. + * Each entry may be a plain string or a platform-specific map. + * Entries that cannot be resolved are excluded. + */ + public static List resolveAll(List targetDirs) { + if (targetDirs == null) { return Collections.emptyList(); } + return targetDirs.stream() + .map(AiAssistExtensionsPathResolver::resolve) + .filter(Objects::nonNull) + .toList(); + } /** * Resolve a target-dir value which may be either a plain string diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java new file mode 100644 index 00000000000..d1d67fc16bb --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java @@ -0,0 +1,265 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; +import java.util.zip.ZipFile; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fortify.cli.common.crypto.helper.SignatureHelper; +import com.fortify.cli.common.crypto.helper.SignatureHelper.SignatureStatus; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.rest.unirest.UnirestHelper; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionArtifactDescriptor; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionVersionDescriptor; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionsHelper; + +/** + * Resolves and provides access to extension source contents. + * Downloads the extensions zip using tool definitions for URL and signature, + * and reads the distribution descriptor from the tool-definitions zip. + */ +public final class AiAssistExtensionsSourceHandler implements AutoCloseable { + /** Tool name as registered in tool-definitions */ + static final String TOOL_NAME = "ai-assistant-extensions"; + /** Embedded zip containing the distribution descriptor and its detached signature */ + static final String DISTRIBUTION_ZIP = "ai-assistant-extensions-distribution.zip"; + /** Distribution descriptor file within the embedded zip */ + static final String DISTRIBUTION_FILE = "v1.yaml"; + /** Detached RSA-SHA256 signature for the distribution descriptor */ + static final String DISTRIBUTION_SIG = "v1.yaml.rsa_sha256"; + + private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsSourceHandler.class); + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + private final Path extractedDir; + private final boolean tempDir; + private final String version; + + private AiAssistExtensionsSourceHandler(Path extractedDir, boolean tempDir, String version) { + this.extractedDir = extractedDir; + this.tempDir = tempDir; + this.version = version; + } + + public String getVersion() { return version; } + public Path getExtractedDir() { return extractedDir; } + + /** + * Resolve from a local source (directory or zip file), for --source override. + */ + public static AiAssistExtensionsSourceHandler fromLocalSource(String source) { + var path = Path.of(source); + if (Files.isDirectory(path)) { + return new AiAssistExtensionsSourceHandler(path.toAbsolutePath(), false, "local"); + } + if (Files.isRegularFile(path)) { + return fromZipFile(path, "local"); + } + throw new FcliSimpleException("Source not found: " + source); + } + + /** + * Resolve from tool definitions: download the extensions zip for the given + * version, verify its signature, and extract. + */ + public static AiAssistExtensionsSourceHandler fromToolDefinitions( + ToolDefinitionVersionDescriptor versionDesc, + DigestMismatchAction onDigestMismatch) { + var artifact = resolveArtifact(versionDesc); + try { + var tempZip = Files.createTempFile("fcli-extensions-", ".zip"); + try { + UnirestHelper.download("ai-assist", artifact.getDownloadUrl(), tempZip.toFile()); + verifyZipSignature(tempZip, artifact, onDigestMismatch); + return fromZipFile(tempZip, versionDesc.getVersion()); + } finally { + Files.deleteIfExists(tempZip); + } + } catch (IOException e) { + throw new FcliTechnicalException("Error downloading extensions", e); + } + } + + private static ToolDefinitionArtifactDescriptor resolveArtifact( + ToolDefinitionVersionDescriptor versionDesc) { + var binaries = versionDesc.getBinaries(); + if (binaries.containsKey("any")) { return binaries.get("any"); } + if (binaries.size() == 1) { return binaries.values().iterator().next(); } + throw new FcliSimpleException( + "Cannot determine artifact for agent-extensions version " + versionDesc.getVersion()); + } + + private static void verifyZipSignature(Path zipPath, + ToolDefinitionArtifactDescriptor artifact, + DigestMismatchAction onDigestMismatch) { + if (artifact.getRsa_sha256() == null) { return; } + try { + var fileBytes = Files.readAllBytes(zipPath); + var status = SignatureHelper.fortifySignatureVerifier() + .verify(fileBytes, artifact.getRsa_sha256()); + if (status != SignatureStatus.VALID) { + var msg = "Extensions zip signature verification failed (status: " + status + ")"; + switch (onDigestMismatch) { + case fail -> throw new FcliSimpleException(msg); + case warn -> LOG.warn("WARNING: {}", msg); + } + } + } catch (IOException e) { + throw new FcliTechnicalException("Error verifying zip signature", e); + } + } + + private static AiAssistExtensionsSourceHandler fromZipFile(Path zipPath, String version) { + try { + var tempDir = Files.createTempDirectory("fcli-extensions-"); + try (var zipFile = new ZipFile(zipPath.toFile())) { + var entries = zipFile.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + var entryPath = tempDir.resolve(normalizePath(entry.getName())); + if (!entryPath.normalize().startsWith(tempDir)) { + throw new FcliSimpleException( + "Zip entry contains path traversal: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(entryPath); + } else { + Files.createDirectories(entryPath.getParent()); + try (InputStream is = zipFile.getInputStream(entry)) { + Files.copy(is, entryPath); + } + } + } + } + return new AiAssistExtensionsSourceHandler(tempDir, true, version); + } catch (IOException e) { + throw new FcliTechnicalException("Error extracting extensions zip: " + zipPath, e); + } + } + + private static String normalizePath(String path) { + if (path.startsWith("./")) { return path.substring(2); } + return path; + } + + /** + * Read and parse the content-manifest.yaml from the extensions zip. + */ + public AiAssistExtensionsContentManifestDescriptor readContentManifest() { + var manifestPath = extractedDir.resolve("content-manifest.yaml"); + if (!Files.isRegularFile(manifestPath)) { + throw new FcliSimpleException("content-manifest.yaml not found in extensions source"); + } + try { + return YAML_MAPPER.readValue(manifestPath.toFile(), + AiAssistExtensionsContentManifestDescriptor.class); + } catch (IOException e) { + throw new FcliTechnicalException("Error reading content-manifest.yaml", e); + } + } + + /** + * Read the distribution descriptor from the nested distribution zip + * inside tool-definitions.yaml.zip. Verifies the detached RSA-SHA256 + * signature before parsing. + */ + public static AiAssistExtensionsDistributionDescriptor readDistributionDescriptor( + boolean verifySignature) { + try (var zipFs = ToolDefinitionsHelper.openEmbeddedZipFileSystem(DISTRIBUTION_ZIP)) { + var yamlPath = zipFs.getPath(DISTRIBUTION_FILE); + var yamlBytes = Files.readAllBytes(yamlPath); + if (verifySignature) { + var sigPath = zipFs.getPath(DISTRIBUTION_SIG); + if (!Files.exists(sigPath)) { + throw new FcliSimpleException( + "Signature file '" + DISTRIBUTION_SIG + "' not found in distribution zip"); + } + var signature = Files.readString(sigPath).trim(); + var status = SignatureHelper.fortifySignatureVerifier() + .verify(yamlBytes, signature); + if (status != SignatureStatus.VALID) { + LOG.warn("Distribution descriptor signature status: {}", status); + } + } + return YAML_MAPPER.readValue(yamlBytes, + AiAssistExtensionsDistributionDescriptor.class); + } catch (IOException e) { + throw new FcliTechnicalException("Error reading distribution descriptor", e); + } + } + + public byte[] readFileBytes(String relativePath) { + var filePath = extractedDir.resolve(relativePath); + if (!Files.isRegularFile(filePath)) { return null; } + try { + return Files.readAllBytes(filePath); + } catch (IOException e) { + throw new FcliTechnicalException("Error reading file: " + relativePath, e); + } + } + + public boolean exists(String relativePath) { + return Files.exists(extractedDir.resolve(relativePath)); + } + + public Stream listFiles(String relativePath) { + var dir = extractedDir.resolve(relativePath); + if (!Files.isDirectory(dir)) { return Stream.empty(); } + try { + return Files.walk(dir) + .filter(Files::isRegularFile) + .map(p -> extractedDir.relativize(p)); + } catch (IOException e) { + throw new FcliTechnicalException("Error listing files in: " + relativePath, e); + } + } + + public Stream listDirs(String relativePath) { + var dir = extractedDir.resolve(relativePath); + if (!Files.isDirectory(dir)) { return Stream.empty(); } + try { + return Files.list(dir).filter(Files::isDirectory); + } catch (IOException e) { + throw new FcliTechnicalException("Error listing dirs in: " + relativePath, e); + } + } + + @Override + public void close() { + if (tempDir) { + try { + Files.walk(extractedDir) + .sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } catch (IOException e) { + LOG.debug("Error cleaning up temp dir: {}", extractedDir, e); + } + } + } + + /** Digest mismatch handling (mirrors tool module pattern). */ + public enum DigestMismatchAction { warn, fail } +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsStateDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateDescriptor.java similarity index 66% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsStateDescriptor.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateDescriptor.java index ace2ef7f22b..977bcac58b9 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsStateDescriptor.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateDescriptor.java @@ -10,7 +10,9 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.helper; +package com.fortify.cli.ai_assist.extensions.helper; + +import java.util.List; import com.fasterxml.jackson.annotation.JsonProperty; import com.formkiq.graalvm.annotations.Reflectable; @@ -21,18 +23,23 @@ import lombok.NoArgsConstructor; /** - * Per-file state descriptor stored under the fcli state directory. + * State descriptor stored per (assistantId, contentType) under the fcli state directory. * Tracks what was installed, where, and from which source version. */ -@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data -public class AgentExtensionsStateDescriptor { +@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) @Data +public class AiAssistExtensionsStateDescriptor { private String assistant; private String assistantId; - private String file; private String contentType; private String targetDir; - private String targetPath; private String sourceVersion; @JsonProperty("timestamp") private String timestamp; + private List files; + + @Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data + public static class FileEntry { + private String source; + private String target; + } } diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsTargetDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDescriptor.java similarity index 81% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsTargetDescriptor.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDescriptor.java index 16ec07b1acf..83d807ed0f6 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/extensions/helper/AgentExtensionsTargetDescriptor.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDescriptor.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.extensions.helper; +package com.fortify.cli.ai_assist.extensions.helper; import java.util.List; @@ -24,13 +24,11 @@ * Per-target configuration within an assistant definition. */ @Reflectable @NoArgsConstructor @Data -public class AgentExtensionsTargetDescriptor { +public class AiAssistExtensionsTargetDescriptor { @JsonProperty("content-type") private String contentType; - @JsonProperty("target-dir") - private Object targetDir; - @JsonProperty("skip-if") - private Object skipIf; + @JsonProperty("target-dirs") + private List targetDirs; @JsonProperty("source-entries") private List sourceEntries; } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsVersionOutputDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsVersionOutputDescriptor.java new file mode 100644 index 00000000000..c2ad953631a --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsVersionOutputDescriptor.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Output record for the list-versions command. + */ +@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data +public class AiAssistExtensionsVersionOutputDescriptor { + private String version; + private String aliases; + private boolean stable; +} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCommands.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCommands.java similarity index 72% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCommands.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCommands.java index fcc620a2aba..63352d0155f 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCommands.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCommands.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.cli.cmd; +package com.fortify.cli.ai_assist.mcp.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; @@ -19,9 +19,9 @@ @Command( name = "mcp", subcommands = { - AgentMCPStartStdioCommand.class, - AgentMCPStartHttpCommand.class, - AgentMCPCreateHttpConfigCommand.class + AiAssistMCPStartStdioCommand.class, + AiAssistMCPStartHttpCommand.class, + AiAssistMCPCreateHttpConfigCommand.class } ) -public class AgentMCPCommands extends AbstractContainerCommand {} +public class AiAssistMCPCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCreateHttpConfigCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java similarity index 95% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCreateHttpConfigCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java index 6b1d55b4b02..ca14888ed6c 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPCreateHttpConfigCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.cli.cmd; +package com.fortify.cli.ai_assist.mcp.cli.cmd; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -27,7 +27,7 @@ @Command(name = "create-http-config") @MCPExclude -public class AgentMCPCreateHttpConfigCommand extends AbstractRunnableCommand { +public class AiAssistMCPCreateHttpConfigCommand extends AbstractRunnableCommand { @Option(names = {"--type", "-t"}, required = true) private HttpConfigType type; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java similarity index 93% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java index 730490f11f1..af3c6e2e440 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartHttpCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.cli.cmd; +package com.fortify.cli.ai_assist.mcp.cli.cmd; import java.nio.file.Path; import java.time.Duration; @@ -21,12 +21,12 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fortify.cli.agent.mcp.helper.MCPImportedActionMcpSpecsFactory; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.http.JdkHttpServerMcpStatelessTransport; -import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpAuthHeaderParser; -import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfigLoader; -import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpSessionDescriptorResolver; +import com.fortify.cli.ai_assist.mcp.helper.MCPImportedActionMcpSpecsFactory; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.http.JdkHttpServerMcpStatelessTransport; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpAuthHeaderParser; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfigLoader; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpSessionDescriptorResolver; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliExecutionContext; @@ -55,7 +55,7 @@ @Command(name = "start-http") @MCPExclude @Slf4j -public class AgentMCPStartHttpCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { +public class AiAssistMCPStartHttpCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); @Option(names = {"--config", "-c"}, required = true) diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java similarity index 95% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java index 804aa3c7093..bacebf6ba5d 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/cli/cmd/AgentMCPStartStdioCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.cli.cmd; +package com.fortify.cli.ai_assist.mcp.cli.cmd; import java.io.FilterInputStream; import java.io.IOException; @@ -28,19 +28,19 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.IMCPToolArgHandler; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerActionOption; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; -import com.fortify.cli.agent.mcp.helper.runner.IMCPToolRunner; -import com.fortify.cli.agent.mcp.helper.runner.MCPResourceFcliRunnerFunction; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerAction; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerFunction; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerFunctionStreaming; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerPlainText; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerRecords; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerRecordsPaged; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.IMCPToolArgHandler; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlerActionOption; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.ai_assist.mcp.helper.runner.IMCPToolRunner; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPResourceFcliRunnerFunction; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerAction; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerFunction; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerFunctionStreaming; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerPlainText; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerRecords; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerRecordsPaged; import com.fortify.cli.common.action.helper.ActionLoaderHelper; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; @@ -88,7 +88,7 @@ @Command(name = "start-stdio") @MCPExclude // Doesn't make sense to allow mcp-server start command to be called from MCP server @Slf4j -public class AgentMCPStartStdioCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { +public class AiAssistMCPStartStdioCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { @Option(names={"--module", "-m"}, required = false) private McpModule module; @DisableTest(TestType.MULTI_OPT_PLURAL_NAME) @Option(names={"--import"}, split=",") private List importFiles; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPImportedActionMcpSpecsFactory.java similarity index 95% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPImportedActionMcpSpecsFactory.java index 4369d837961..2d7d327e0e8 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPImportedActionMcpSpecsFactory.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPImportedActionMcpSpecsFactory.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper; +package com.fortify.cli.ai_assist.mcp.helper; import java.nio.file.Path; import java.util.ArrayList; @@ -18,10 +18,10 @@ import java.util.function.Supplier; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; -import com.fortify.cli.agent.mcp.helper.runner.MCPResourceFcliRunnerFunction; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerFunction; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerFunctionStreaming; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPResourceFcliRunnerFunction; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerFunction; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerFunctionStreaming; import com.fortify.cli.common.action.helper.ActionLoaderHelper; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPJobManager.java similarity index 99% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPJobManager.java index 36c9710fedc..781f6c1eaae 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPJobManager.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPJobManager.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper; +package com.fortify.cli.ai_assist.mcp.helper; import java.time.Instant; import java.util.ArrayList; @@ -31,7 +31,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolAsyncJobManager; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolAsyncJobManager; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.AsyncJobManager; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPReflectConfigGenerator.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPReflectConfigGenerator.java similarity index 98% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPReflectConfigGenerator.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPReflectConfigGenerator.java index db054aade56..099ee603838 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/MCPReflectConfigGenerator.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPReflectConfigGenerator.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper; +package com.fortify.cli.ai_assist.mcp.helper; import java.io.IOException; import java.nio.file.Files; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java similarity index 99% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java index d1f294fbd03..35fad69c69a 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.util.Collection; import java.util.Map; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/IMCPToolArgHandler.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/IMCPToolArgHandler.java similarity index 95% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/IMCPToolArgHandler.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/IMCPToolArgHandler.java index 13cbf7781ef..144fa2a994c 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/IMCPToolArgHandler.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/IMCPToolArgHandler.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.util.Map; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerActionOption.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerActionOption.java similarity index 98% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerActionOption.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerActionOption.java index 260d07ebae3..059de4fa1c3 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerActionOption.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerActionOption.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.util.Collection; import java.util.Map; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliOption.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerFcliOption.java similarity index 97% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliOption.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerFcliOption.java index 65dc494e51b..1fae4a99137 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliOption.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerFcliOption.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.util.stream.Collectors; import java.util.stream.Stream; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliParam.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerFcliParam.java similarity index 97% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliParam.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerFcliParam.java index be75b8b76e5..b5a3df7b3a9 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerFcliParam.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerFcliParam.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.lang.reflect.Field; import java.util.stream.Collectors; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerPaging.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerPaging.java similarity index 95% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerPaging.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerPaging.java index c148a245cc8..d5a1a24a6cc 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerPaging.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerPaging.java @@ -10,11 +10,11 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.util.Map; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerRecordsPaged; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerRecordsPaged; import com.fortify.cli.common.json.JsonHelper; import io.modelcontextprotocol.spec.McpSchema.JsonSchema; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerQuery.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerQuery.java similarity index 99% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerQuery.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerQuery.java index 10f765099e5..326b4ae0ebc 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlerQuery.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerQuery.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlers.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlers.java similarity index 98% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlers.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlers.java index 9ecbe1d3178..60f3dd5507d 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/arg/MCPToolArgHandlers.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlers.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java similarity index 97% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java index fd9c19f7233..5d637db2982 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.http; +package com.fortify.cli.ai_assist.mcp.helper.http; import java.io.IOException; import java.io.InputStream; @@ -26,8 +26,8 @@ import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; -import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfig.ServerConfig; -import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfig.TlsConfig; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfig.ServerConfig; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfig.TlsConfig; import com.fortify.cli.common.exception.FcliSimpleException; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpAuthHeaderParser.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpAuthHeaderParser.java similarity index 99% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpAuthHeaderParser.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpAuthHeaderParser.java index 0f1109eeb74..1aafc6877b1 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpAuthHeaderParser.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpAuthHeaderParser.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.http; +package com.fortify.cli.ai_assist.mcp.helper.http; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfig.java similarity index 99% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfig.java index 172c90b9ed5..ac83610180e 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfig.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.http; +package com.fortify.cli.ai_assist.mcp.helper.http; import java.net.InetSocketAddress; import java.nio.file.Path; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfigLoader.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfigLoader.java similarity index 98% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfigLoader.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfigLoader.java index aea1be1f976..e5b40f5e2f3 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpConfigLoader.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfigLoader.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.http; +package com.fortify.cli.ai_assist.mcp.helper.http; import java.io.IOException; import java.nio.file.Files; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java similarity index 99% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 6805e8f3fd2..344004c1ba9 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.http; +package com.fortify.cli.ai_assist.mcp.helper.http; import java.nio.charset.StandardCharsets; import java.nio.file.Files; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/ParsedAuthorization.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/ParsedAuthorization.java similarity index 97% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/ParsedAuthorization.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/ParsedAuthorization.java index 5145674fdec..861b1b37644 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/http/ParsedAuthorization.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/ParsedAuthorization.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.http; +package com.fortify.cli.ai_assist.mcp.helper.http; import org.apache.commons.lang3.StringUtils; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/AbstractMCPToolFcliRunner.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/AbstractMCPToolFcliRunner.java similarity index 90% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/AbstractMCPToolFcliRunner.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/AbstractMCPToolFcliRunner.java index 4002baee9c6..1904bbba336 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/AbstractMCPToolFcliRunner.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/AbstractMCPToolFcliRunner.java @@ -10,10 +10,10 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlers; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import picocli.CommandLine.Model.CommandSpec; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/IMCPToolRunner.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/IMCPToolRunner.java similarity index 95% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/IMCPToolRunner.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/IMCPToolRunner.java index 64f74975836..3fe2d09f299 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/IMCPToolRunner.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/IMCPToolRunner.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPResourceFcliRunnerFunction.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java similarity index 98% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPResourceFcliRunnerFunction.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java index 23aff5a5fc6..57e4df1def4 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPResourceFcliRunnerFunction.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.List; import java.util.regex.Pattern; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolAsyncJobManager.java similarity index 98% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolAsyncJobManager.java index 546850fc969..7279a9949dd 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolAsyncJobManager.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolAsyncJobManager.java @@ -10,14 +10,14 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import com.fortify.cli.common.concurrent.job.CachingJobEventListener; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliPagedHelper.java similarity index 98% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliPagedHelper.java index 3e8c1a8a8d2..d13171b72a0 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliPagedHelper.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliPagedHelper.java @@ -10,13 +10,13 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.List; import java.util.Optional; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlerPaging; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerAction.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerAction.java similarity index 94% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerAction.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerAction.java index cf78522375e..ab1253f9111 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerAction.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerAction.java @@ -10,15 +10,15 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.IMCPToolArgHandler; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.IMCPToolArgHandler; import com.fortify.cli.common.action.model.Action; import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunction.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerFunction.java similarity index 96% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunction.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerFunction.java index 79317af86b5..7350c6bc54e 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunction.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerFunction.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.Map; import java.util.concurrent.Callable; @@ -18,7 +18,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.util.OutputHelper.Result; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java similarity index 95% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java index 1b4007f72ed..10ce140e2b4 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java @@ -10,14 +10,14 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlerPaging; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlerPaging; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.concurrent.job.task.AsyncTaskActionFunction; import com.fortify.cli.common.json.JsonHelper; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerHelper.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerHelper.java similarity index 98% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerHelper.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerHelper.java index d0c66e3f934..7e0fe764899 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerHelper.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerHelper.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerPlainText.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerPlainText.java similarity index 93% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerPlainText.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerPlainText.java index 88cbdaa9d45..bec9eb64d10 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerPlainText.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerPlainText.java @@ -10,13 +10,13 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlers; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecords.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerRecords.java similarity index 93% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecords.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerRecords.java index ed008a98a2c..97107065d3d 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecords.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerRecords.java @@ -10,15 +10,15 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.ArrayList; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlers; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java similarity index 92% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java index 92f0ff6e7c1..74349da1ff6 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java @@ -10,10 +10,10 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlers; import com.fortify.cli.common.concurrent.job.task.AsyncTaskFcliCommand; import io.modelcontextprotocol.server.McpSyncServerExchange; diff --git a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResult.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolResult.java similarity index 99% rename from fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResult.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolResult.java index 7bacbca48af..1be0b2d8d00 100644 --- a/fcli-core/fcli-agent/src/main/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResult.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolResult.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import java.util.List; diff --git a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties new file mode 100644 index 00000000000..6fa988d3f38 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties @@ -0,0 +1,72 @@ +fcli.ai-assist.usage.header = (PREVIEW) Manage AI assistant integrations +fcli.ai-assist.usage.description = Manage AI-related functionality like MCP servers and skills. + +# fcli ai-assist extensions +fcli.ai-assist.extensions.usage.header = (PREVIEW) Manage Fortify extensions for AI coding assistants +fcli.ai-assist.extensions.usage.description = Install, update, uninstall, or check status of Fortify extensions (skills, agents, plugins) for AI coding assistants like Claude Code, GitHub Copilot, OpenAI Codex, and Gemini CLI. +fcli.ai-assist.extensions.install.usage.header = Install Fortify extensions to detected coding assistants +fcli.ai-assist.extensions.install.usage.description = Download and install Fortify extensions (skills, agents, plugins) to detected AI coding assistants. By default, extensions are downloaded from the official Fortify tool definitions. Use --source to specify a local zip or directory. +fcli.ai-assist.extensions.install.output.table.args = assistant,contentType,targetDir,fileCount,__action__ +fcli.ai-assist.extensions.uninstall.usage.header = Remove installed Fortify extensions from coding assistants +fcli.ai-assist.extensions.uninstall.usage.description = Remove previously installed Fortify extensions from AI coding assistant directories and clean up fcli state. +fcli.ai-assist.extensions.uninstall.output.table.args = assistant,contentType,targetDir,fileCount,__action__ +fcli.ai-assist.extensions.update.usage.header = Update installed Fortify extensions +fcli.ai-assist.extensions.update.usage.description = Update installed extensions: add new files, update changed files, and remove files no longer in the source. Can also be used for first-time install. +fcli.ai-assist.extensions.update.output.table.args = assistant,contentType,targetDir,fileCount,__action__ +fcli.ai-assist.extensions.list-versions.usage.header = List available extension versions +fcli.ai-assist.extensions.list-versions.usage.description = List all available versions of Fortify AI assistant extensions from the tool definitions, including aliases and stability status. +fcli.ai-assist.extensions.list-versions.output.table.args = version,aliases,stable +fcli.ai-assist.extensions.list-installed.usage.header = List installed extensions +fcli.ai-assist.extensions.list-installed.usage.description = List currently installed Fortify extensions per coding assistant and content type. +fcli.ai-assist.extensions.list-installed.output.table.args = assistant,contentType,targetDir,fileCount,sourceVersion +fcli.ai-assist.extensions.list-assistants.usage.header = List supported AI coding assistants +fcli.ai-assist.extensions.list-assistants.usage.description = List all AI coding assistants defined in the distribution descriptor, showing supported content types, detection status, and installation status. Use --detect to run actual detection checks (glob/command). +fcli.ai-assist.extensions.list-assistants.output.table.args = id,name,contentTypesString,detected,installed + +# Shared option descriptions for extensions commands +fcli.ai-assist.extensions.assistants = Restrict to specific assistants (e.g., --assistants claude,copilot). Default: all detected. +fcli.ai-assist.extensions.exclude-assistants = Exclude specific assistants from the operation. +fcli.ai-assist.extensions.version = Extension version to install or update to (e.g., 1.0.0, latest, stable). Default: latest. +fcli.ai-assist.extensions.source = Extensions source: local zip file or local directory. Overrides version resolution from tool definitions. +fcli.ai-assist.extensions.dir = Install to a specific directory instead of auto-detected locations. Requires --content-types. Bypasses assistant detection. +fcli.ai-assist.extensions.content-types = Filter by content type: skills, agents, plugins. Default: all. Required with --dir. +fcli.ai-assist.extensions.on-digest-mismatch = Action when downloaded zip digest does not match tool definitions: warn, fail. Default: fail. +fcli.ai-assist.extensions.confirm = Skip confirmation prompt. +fcli.ai-assist.extensions.dry-run = Show what would be done without performing any changes. +fcli.ai-assist.extensions.detect = Run detection checks (glob patterns, command existence) to determine which assistants are present. Without this flag, detected column shows N/A. + +# fcli ai-assist mcp +fcli.ai-assist.mcp.usage.header = (PREVIEW) Manage fcli MCP server commands for AI assistants +fcli.ai-assist.mcp.usage.description = Start fcli MCP servers for AI assistants, and generate HTTP MCP server config templates. +fcli.ai-assist.mcp.start-stdio.usage.header = (PREVIEW) Start fcli MCP server on stdio for AI integration +fcli.ai-assist.mcp.start-stdio.usage.description = Start the fcli MCP server over stdio. This command exposes fcli module commands and/or \ + imported action functions as MCP tools to AI clients.%n\ + %nTHREAD MODEL:%n\ + - Work threads (--work-threads): execute MCP tool calls. Each concurrent tool call occupies one thread for its\n\ + full duration. Size to the maximum number of tool calls the AI may invoke in parallel.%n\ + - Progress threads (--progress-threads): poll progress for long-running jobs at regular intervals. One thread\n\ + is consumed per active long-running job during each poll. The default of 4 is sufficient for most use cases.%n\ + - Async background threads (--async-bg-threads): run background async streaming jobs (e.g. run.fcli steps\n\ + with streaming output). Increase if actions make heavy use of async streaming.%n +fcli.ai-assist.mcp.start-stdio.module = Fcli module to expose through this MCP server instance. +fcli.ai-assist.mcp.start-stdio.import = Action YAML files to import. Exported functions are registered as MCP tools or resources based on function metadata. +fcli.ai-assist.mcp.start-stdio.work-threads = Number of worker threads used to execute MCP tool jobs concurrently. Increase for higher parallelism if AI invokes multiple tools simultaneously. +fcli.ai-assist.mcp.start-stdio.progress-threads = Number of threads used for updating and tracking job progress for long-running jobs. +fcli.ai-assist.mcp.start-stdio.job-safe-return = Maximum time to wait synchronously for a job result before returning an in_progress placeholder. Specify duration like 25s, 2m, 1h. +fcli.ai-assist.mcp.start-stdio.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). +fcli.ai-assist.mcp.start-stdio.async-bg-threads = Number of background threads for running async streaming jobs. Default: 2. + +fcli.ai-assist.mcp.start-http.usage.header = (PREVIEW) Start import-only HTTP fcli MCP server for AI integration +fcli.ai-assist.mcp.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. Generate a sample config file with 'fcli ai-assist mcp create-http-config --type ' and customize the generated YAML for your environment. The server listens for MCP POST requests on the /mcp endpoint. Each request must include the product-specific auth header as semicolon-separated key=value pairs; escape literal '\\', ';', or '=' characters as '\\\\', '\\;', or '\\='.\ + %n%nAUTH HEADERS (per HTTP request):\ + %n- SSC mode: X-AUTH-SSC: token=[;sc-sast-token=]\ + %n- FoD mode, user/PAT auth: X-AUTH-FOD: tenant=;user=;pat=\ + %n- FoD mode, client auth: X-AUTH-FOD: client-id=;client-secret=\ + %nExactly one FoD auth mode must be specified per request. +fcli.ai-assist.mcp.start-http.config = Path to HTTP MCP YAML config file. Generate a template with 'fcli ai-assist mcp create-http-config'. + +fcli.ai-assist.mcp.create-http-config.usage.header = Generate a sample HTTP MCP server config file +fcli.ai-assist.mcp.create-http-config.usage.description = Create a sample HTTP MCP config file for the selected product type. +fcli.ai-assist.mcp.create-http-config.type = Product type for template generation: ssc or fod. +fcli.ai-assist.mcp.create-http-config.config = Output path for the generated config file. Default: mcp-http-config.yaml. +fcli.ai-assist.mcp.create-http-config.force = Overwrite an existing config file if it already exists. diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-fod.yaml similarity index 100% rename from fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml rename to fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-fod.yaml diff --git a/fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-ssc.yaml similarity index 100% rename from fcli-core/fcli-agent/src/main/resources/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml rename to fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-ssc.yaml diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java similarity index 99% rename from fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java index 7a6338965d6..80c22aa07ae 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.http; +package com.fortify.cli.ai_assist.mcp.helper.http; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java similarity index 96% rename from fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java index 1de453afaed..48b547cd3ce 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolFcliRunnerFunctionStreamingTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java similarity index 95% rename from fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java index bed44966ba1..e9c3cb34b35 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/runner/MCPToolResultTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.helper.runner; +package com.fortify.cli.ai_assist.mcp.helper.runner; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java similarity index 99% rename from fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java index 1a7c63f2acf..97f8f15c284 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.unit; +package com.fortify.cli.ai_assist.mcp.unit; import static org.junit.jupiter.api.Assertions.*; @@ -24,7 +24,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; import com.fortify.cli.common.cli.util.FcliActionState; import com.fortify.cli.common.cli.util.FcliExecutionContext; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java similarity index 93% rename from fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java index 7419bea8568..c4c2784cad8 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.unit; +package com.fortify.cli.ai_assist.mcp.unit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -21,8 +21,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfig; -import com.fortify.cli.agent.mcp.helper.http.MCPServerHttpConfigLoader; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfig; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfigLoader; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.util.EnvHelper; diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java similarity index 98% rename from fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java index 97d65f34651..21b7bcfdc93 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.unit; +package com.fortify.cli.ai_assist.mcp.unit; import static org.junit.jupiter.api.Assertions.*; @@ -18,7 +18,7 @@ import org.junit.jupiter.api.Test; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlers; import picocli.CommandLine; import picocli.CommandLine.Command; diff --git a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java similarity index 94% rename from fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java index e3be67fcaf9..df0d5e3f1e7 100644 --- a/fcli-core/fcli-agent/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java @@ -10,7 +10,7 @@ * herein. The information contained herein is subject to change * without notice. */ -package com.fortify.cli.agent.mcp.unit; +package com.fortify.cli.ai_assist.mcp.unit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -22,9 +22,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.fortify.cli.agent.mcp.helper.MCPJobManager; -import com.fortify.cli.agent.mcp.helper.arg.MCPToolArgHandlers; -import com.fortify.cli.agent.mcp.helper.runner.MCPToolFcliRunnerRecords; +import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.arg.MCPToolArgHandlers; +import com.fortify.cli.ai_assist.mcp.helper.runner.MCPToolFcliRunnerRecords; import com.fortify.cli.common.concurrent.job.AsyncJobManager; import picocli.CommandLine; diff --git a/fcli-core/fcli-app/build.gradle.kts b/fcli-core/fcli-app/build.gradle.kts index 46a663419c4..53eec13c87d 100644 --- a/fcli-core/fcli-app/build.gradle.kts +++ b/fcli-core/fcli-app/build.gradle.kts @@ -7,7 +7,7 @@ plugins { // Inter-project dependencies val refs = listOf( - "fcliCommonRef","fcliActionRef","fcliAgentRef","fcliAviatorRef","fcliConfigRef","fcliFoDRef","fcliSSCRef","fcliSCSastRef","fcliSCDastRef","fcliToolRef","fcliLicenseRef","fcliUtilRef" + "fcliCommonRef","fcliActionRef","fcliAiAssistRef","fcliAviatorRef","fcliConfigRef","fcliFoDRef","fcliSSCRef","fcliSCSastRef","fcliSCDastRef","fcliToolRef","fcliLicenseRef","fcliUtilRef" ) references@ for (r in refs) { val p = project.findProperty(r) as String? ?: continue@references @@ -43,7 +43,7 @@ val generateMCPReflectConfig = tasks.register("generateMCPReflectConfi inputs.files(configurations.runtimeClasspath, sourceSets.main.get().runtimeClasspath) outputs.file(outputFile) classpath(configurations.runtimeClasspath, sourceSets.main.get().runtimeClasspath) - mainClass.set("com.fortify.cli.agent.mcp.helper.MCPReflectConfigGenerator") + mainClass.set("com.fortify.cli.ai_assist.mcp.helper.MCPReflectConfigGenerator") args(outputFile.get().asFile.absolutePath) } diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java index 239bdbdeffc..7bf4363b6ad 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java @@ -12,7 +12,7 @@ */ package com.fortify.cli.app._main.cli.cmd; -import com.fortify.cli.agent._main.cli.cmd.AgentCommands; +import com.fortify.cli.ai_assist._main.cli.cmd.AiAssistCommands; import com.fortify.cli.app.FortifyCLIVersionProvider; import com.fortify.cli.aviator._main.cli.cmd.AviatorCommands; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; @@ -47,7 +47,7 @@ versionProvider = FortifyCLIVersionProvider.class, subcommands = { GenericActionCommands.class, - AgentCommands.class, + AiAssistCommands.class, AviatorCommands.class, ConfigCommands.class, FoDCommands.class, diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolDependency.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolDependency.java deleted file mode 100644 index 6beeb060b0a..00000000000 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolDependency.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.tool._common.helper; - -import java.util.HashMap; -import java.util.Map; - -import lombok.RequiredArgsConstructor; - -/** - * Enumeration of tool dependencies that have definitions but are not exposed - * as user-facing CLI commands. These are typically internal dependencies used - * by other tools (e.g., JRE required by ScanCentral Client). - * - * Unlike the Tool enum, ToolDependency entries do not have associated CLI commands - * and do not define binary names or environment variable prefixes. - * - * @author Ruud Senden - */ -@RequiredArgsConstructor -public enum ToolDependency { - JRE(new ToolDependencyHelperJre()); - - private static final Map TOOL_DEPENDENCY_NAME_MAP = new HashMap<>(); - - static { - for (ToolDependency dependency : values()) { - TOOL_DEPENDENCY_NAME_MAP.put(dependency.getToolName(), dependency); - } - } - - /** - * Get the ToolDependency enum entry by tool name. - * @param toolName the tool name (e.g., "jre") - * @return the corresponding ToolDependency enum entry, or null if not found - */ - public static ToolDependency getByToolName(String toolName) { - return TOOL_DEPENDENCY_NAME_MAP.get(toolName); - } - - private final IToolDependencyHelper toolDependencyHelper; - - /** - * Get the tool name identifier (e.g., "jre"). - */ - public String getToolName() { - return toolDependencyHelper.getToolName(); - } - - /** - * Interface defining tool dependency-specific helper methods. - * Each tool dependency implementation provides its own concrete helper class. - */ - public interface IToolDependencyHelper { - String getToolName(); - } - - /** - * Helper implementation for jre tool dependency. - */ - private static final class ToolDependencyHelperJre implements IToolDependencyHelper { - private static final String TOOL_NAME = "jre"; - - @Override - public String getToolName() { - return TOOL_NAME; - } - } -} diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java index 60bf945ab7c..b90e407f823 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java @@ -16,20 +16,19 @@ import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.FileTime; import java.util.ArrayList; -import java.util.Arrays; import java.util.Date; import java.util.Enumeration; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; @@ -47,8 +46,6 @@ import com.fortify.cli.common.util.FcliBuildProperties; import com.fortify.cli.common.util.FcliDataHelper; import com.fortify.cli.common.util.FileUtils; -import com.fortify.cli.tool._common.helper.Tool; -import com.fortify.cli.tool._common.helper.ToolDependency; import lombok.SneakyThrows; @@ -174,14 +171,14 @@ private static boolean isValidZip(String source) { if (!Files.exists(zipPath)) { return false; } - Set requiredYamlFiles = getRequiredYamlFileNames(); + Set requiredFiles = getRequiredFileNames(); try (ZipFile zipFile = new ZipFile(zipPath.toFile())) { Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); if (!entry.isDirectory()) { String name = Path.of(entry.getName()).getFileName().toString(); - if (requiredYamlFiles.contains(name)) { + if (requiredFiles.contains(name)) { return true; // At least one required file found } } @@ -257,7 +254,7 @@ private static void validateUserZipContainsRequiredFiles(String source) throws I throw new FcliSimpleException("ZIP file not found: " + sourcePath); } - Set requiredYamlFiles = getRequiredYamlFileNames(); + Set requiredFiles = getRequiredFileNames(); boolean foundAtLeastOne = false; try (ZipFile zipFile = new ZipFile(sourcePath.toFile())) { @@ -266,7 +263,7 @@ private static void validateUserZipContainsRequiredFiles(String source) throws I ZipEntry entry = entries.nextElement(); if (!entry.isDirectory()) { String name = Path.of(entry.getName()).getFileName().toString(); - if (requiredYamlFiles.contains(name)) { + if (requiredFiles.contains(name)) { foundAtLeastOne = true; break; // Found at least one, that's enough } @@ -279,43 +276,34 @@ private static void validateUserZipContainsRequiredFiles(String source) throws I if (!foundAtLeastOne) { throw new FcliSimpleException( "ZIP file does not contain any expected tool definition files. Expected files: " - + String.join(", ", requiredYamlFiles)); + + String.join(", ", requiredFiles)); } } private static void createMergedZipFile(Path dest, String source, Path existingStateZip) throws IOException { try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(dest))) { - for (String yamlFileName : getRequiredYamlFileNames()) { - copyYamlFileFromFirstAvailableSource(yamlFileName, source, existingStateZip, zos); + for (String fileName : getRequiredFileNames()) { + copyFileFromFirstAvailableSource(fileName, source, existingStateZip, zos); } } } - private static void copyYamlFileFromFirstAvailableSource(String yamlFileName, String userSource, + private static void copyFileFromFirstAvailableSource(String fileName, String userSource, Path existingStateZip, ZipOutputStream zos) throws IOException { // Try user-provided source first - if (StringUtils.isNotBlank(userSource) && copyYamlFromZipToZip(Path.of(userSource), yamlFileName, zos)) { + if (StringUtils.isNotBlank(userSource) && copyEntryFromZipToZip(Path.of(userSource), fileName, zos)) { return; } // Fall back to existing state ZIP (if provided) if (existingStateZip != null && Files.exists(existingStateZip) - && copyYamlFromZipToZip(existingStateZip, yamlFileName, zos)) { + && copyEntryFromZipToZip(existingStateZip, fileName, zos)) { return; } // Fall back to internal resource - copyYamlFromResourceZipToZip(DEFINITIONS_INTERNAL_ZIP, yamlFileName, zos); + copyEntryFromResourceZipToZip(DEFINITIONS_INTERNAL_ZIP, fileName, zos); } - /** - * Copies a specific YAML file from a ZIP file to an output ZIP stream. - * - * @param zipPath the source ZIP file path - * @param yamlFileName the name of the YAML file to copy - * @param zos the destination ZIP output stream - * @return true if the file was found and copied, false if not found - * @throws IOException if an I/O error occurs during reading or writing - */ - private static boolean copyYamlFromZipToZip(Path zipPath, String yamlFileName, ZipOutputStream zos) + private static boolean copyEntryFromZipToZip(Path zipPath, String fileName, ZipOutputStream zos) throws IOException { if (!Files.exists(zipPath)) { return false; @@ -324,8 +312,8 @@ private static boolean copyYamlFromZipToZip(Path zipPath, String yamlFileName, Z Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); - if (!entry.isDirectory() && Path.of(entry.getName()).getFileName().toString().equals(yamlFileName)) { - ZipEntry newEntry = new ZipEntry(yamlFileName); + if (!entry.isDirectory() && Path.of(entry.getName()).getFileName().toString().equals(fileName)) { + ZipEntry newEntry = new ZipEntry(fileName); if (entry.getLastModifiedTime() != null) { newEntry.setLastModifiedTime(entry.getLastModifiedTime()); } @@ -341,24 +329,14 @@ private static boolean copyYamlFromZipToZip(Path zipPath, String yamlFileName, Z return false; } - /** - * Copies a specific YAML file from an internal resource ZIP to an output ZIP - * stream. - * - * @param resourceZip the resource path of the internal ZIP file - * @param yamlFileName the name of the YAML file to copy - * @param zos the destination ZIP output stream - * @return true if the file was found and copied, false if not found - * @throws IOException if an I/O error occurs during reading or writing - */ - private static boolean copyYamlFromResourceZipToZip(String resourceZip, String yamlFileName, + private static boolean copyEntryFromResourceZipToZip(String resourceZip, String fileName, ZipOutputStream zos) throws IOException { try (InputStream is = FileUtils.getResourceInputStream(resourceZip); ZipInputStream zis = new ZipInputStream(is)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { - if (!entry.isDirectory() && Path.of(entry.getName()).getFileName().toString().equals(yamlFileName)) { - ZipEntry newEntry = new ZipEntry(yamlFileName); + if (!entry.isDirectory() && Path.of(entry.getName()).getFileName().toString().equals(fileName)) { + ZipEntry newEntry = new ZipEntry(fileName); if (entry.getLastModifiedTime() != null) { newEntry.setLastModifiedTime(entry.getLastModifiedTime()); } @@ -405,9 +383,108 @@ public static final Optional tryGetToolDefinitionR } } + /** + * Read a non-YAML extra file from the tool-definitions zip as a string. + * These are files placed in the extra-files/ directory of the tool-definitions + * repo and included in the published zip alongside tool definition YAMLs. + */ + public static final String readExtraFile(String fileName) { + try (InputStream is = getToolDefinitionsInputStream(); ZipInputStream zis = new ZipInputStream(is)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (fileName.equals(entry.getName())) { + return new String(zis.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + } + throw new FcliSimpleException("Extra file not found in tool definitions: " + fileName); + } catch (IOException e) { + throw new FcliSimpleException("Error reading extra file from tool definitions: " + fileName, e); + } + } + + /** + * Open an embedded zip file from tool-definitions.yaml.zip as a {@link FileSystem}, + * allowing its contents to be read using standard {@link Files} APIs. The returned + * {@link CloseableZipFileSystem} is auto-closeable and handles all cleanup. + *

    + * Uses nested zip {@link FileSystem} instances directly on the state zip — + * no temp files needed. If the state zip doesn't exist yet, the internal + * resource is copied to the state directory first. + */ + public static final CloseableZipFileSystem openEmbeddedZipFileSystem(String zipFileName) { + var stateZip = ensureStateZipExists(); + try { + var outerFs = FileSystems.newFileSystem(stateZip); + try { + var innerZipPath = outerFs.getPath(zipFileName); + if (!Files.exists(innerZipPath)) { + outerFs.close(); + throw new FcliSimpleException( + "Embedded zip not found in tool definitions: " + zipFileName); + } + var innerFs = FileSystems.newFileSystem(innerZipPath); + return new CloseableZipFileSystem(innerFs, outerFs); + } catch (FcliSimpleException e) { + throw e; + } catch (Exception e) { + try { outerFs.close(); } catch (IOException ignored) {} + throw e; + } + } catch (IOException e) { + throw new FcliSimpleException( + "Error opening embedded zip from tool definitions: " + zipFileName, e); + } + } + + /** + * An auto-closeable wrapper around a nested zip {@link FileSystem}. + * Closing this instance closes the inner filesystem, then the outer filesystem. + */ + public static final class CloseableZipFileSystem implements AutoCloseable { + private final FileSystem innerFs; + private final FileSystem outerFs; + + CloseableZipFileSystem(FileSystem innerFs, FileSystem outerFs) { + this.innerFs = innerFs; + this.outerFs = outerFs; + } + + /** Get the root path of the zip file system. */ + public Path getRoot() { + return innerFs.getPath("/"); + } + + /** Resolve a path within the zip file system. */ + public Path getPath(String path) { + return innerFs.getPath(path); + } + + @Override + public void close() { + try { innerFs.close(); } catch (Exception e) { /* ignore */ } + try { outerFs.close(); } catch (Exception e) { /* ignore */ } + } + } + + /** + * Ensure the state zip exists on disk. If it doesn't, copy the internal + * resource to the state directory so all downstream code can work with + * a file on disk. + */ + @SneakyThrows + private static Path ensureStateZipExists() { + if (!Files.exists(DEFINITIONS_STATE_ZIP)) { + createDefinitionsStateDir(DEFINITIONS_STATE_DIR); + try (InputStream is = FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP)) { + Files.copy(is, DEFINITIONS_STATE_ZIP); + } + } + return DEFINITIONS_STATE_ZIP; + } + private static final InputStream getToolDefinitionsInputStream() throws IOException { - return Files.exists(DEFINITIONS_STATE_ZIP) ? Files.newInputStream(DEFINITIONS_STATE_ZIP) - : FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP); + ensureStateZipExists(); + return Files.newInputStream(DEFINITIONS_STATE_ZIP); } private static final void addZipOutputDescriptor(List result) { @@ -434,11 +511,20 @@ private static String determineActionResult(ToolDefinitionsStateDescriptor state return shouldUpdate ? "UPDATED" : "SKIPPED_BY_AGE"; } - private static Set getRequiredYamlFileNames() { - var toolNames = Stream.concat( - Arrays.stream(Tool.values()).map(Tool::getToolName), - Arrays.stream(ToolDependency.values()).map(ToolDependency::getToolName)); - return toolNames.map(s -> s + ".yaml").collect(Collectors.toSet()); + private static Set getRequiredFileNames() { + Set names = new HashSet<>(); + try (InputStream is = FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP); + ZipInputStream zis = new ZipInputStream(is)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (!entry.isDirectory()) { + names.add(Path.of(entry.getName()).getFileName().toString()); + } + } + } catch (IOException e) { + throw new FcliSimpleException("Error reading internal tool definitions", e); + } + return names; } private static final void addYamlOutputDescriptors(List result) { @@ -456,26 +542,26 @@ private static final void addYamlOutputDescriptors(List result, String source, boolean shouldUpdate) { - Set requiredYamlNames = getRequiredYamlFileNames(); + Set requiredNames = getRequiredFileNames(); if (!shouldUpdate) { - addYamlDescriptor(result, requiredYamlNames, "SKIPPED_BY_AGE"); + addYamlDescriptor(result, requiredNames, "SKIPPED_BY_AGE"); } else if (source != null && source.contains("https://")) { - addYamlDescriptor(result, requiredYamlNames, "UPDATED"); + addYamlDescriptor(result, requiredNames, "UPDATED"); } else { - Set foundYamlNames = new HashSet<>(); + Set foundNames = new HashSet<>(); String zipPathOnly = source != null ? Path.of(source).getFileName().toString() : null; if (source != null) { - updateActionResultForUserFile(result, requiredYamlNames, foundYamlNames, zipPathOnly, source); + updateActionResultForUserFile(result, requiredNames, foundNames, zipPathOnly, source); } - updateActionResultForMissingFiles(result, requiredYamlNames, foundYamlNames); + updateActionResultForMissingFiles(result, requiredNames, foundNames); } } private static void updateActionResultForUserFile(List result, - Set requiredYamlNames, Set foundYamlNames, String zipPathOnly, String source) { + Set requiredNames, Set foundNames, String zipPathOnly, String source) { Path zipPath = Path.of(source); if (!Files.exists(zipPath)) { @@ -486,7 +572,7 @@ private static void updateActionResultForUserFile(List result, - Set requiredYamlNames, Set foundYamlNames, String zipPathOnly) { + Set requiredNames, Set foundNames, String zipPathOnly) { String name = Path.of(entry.getName()).getFileName().toString(); Date lastModified = getEntryLastModified(entry); - if (requiredYamlNames.contains(name)) { + if (requiredNames.contains(name)) { result.add(new ToolDefinitionsOutputDescriptor(name, zipPathOnly, lastModified, "UPDATED")); - foundYamlNames.add(name); + foundNames.add(name); } else { result.add(new ToolDefinitionsOutputDescriptor(name, zipPathOnly, lastModified, "IGNORED")); } @@ -514,9 +600,9 @@ private static Date getEntryLastModified(ZipEntry entry) { } private static void updateActionResultForMissingFiles( - List result, Set requiredYamlNames, Set foundYamlNames) { - for (String required : requiredYamlNames) { - if (!foundYamlNames.contains(required)) { + List result, Set requiredNames, Set foundNames) { + for (String required : requiredNames) { + if (!foundNames.contains(required)) { addMissingFileDescriptor(result, required); } } @@ -540,12 +626,12 @@ private static Date getFileOrResourceLastModified(String fileName) { } private static void addYamlDescriptor(List result, - Set requiredYamlNames, String action) { + Set requiredNames, String action) { try (InputStream is = getToolDefinitionsInputStream(); ZipInputStream zis = new ZipInputStream(is)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { String name = Path.of(entry.getName()).getFileName().toString(); - if (requiredYamlNames.contains(name)) { + if (requiredNames.contains(name)) { result.add(new ToolDefinitionsOutputDescriptor(name, ZIP_FILE_NAME, getEntryLastModified(entry), action)); } diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sc_client/cli/cmd/ToolSCClientInstallCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sc_client/cli/cmd/ToolSCClientInstallCommand.java index 2388110a08d..4768ba8330d 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sc_client/cli/cmd/ToolSCClientInstallCommand.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sc_client/cli/cmd/ToolSCClientInstallCommand.java @@ -29,7 +29,6 @@ import com.fortify.cli.common.util.PlatformHelper; import com.fortify.cli.tool._common.cli.cmd.AbstractToolInstallCommand; import com.fortify.cli.tool._common.helper.Tool; -import com.fortify.cli.tool._common.helper.ToolDependency; import com.fortify.cli.tool._common.helper.ToolInstaller; import com.fortify.cli.tool._common.helper.ToolInstaller.BinScriptType; import com.fortify.cli.tool._common.helper.ToolInstaller.DigestMismatchAction; @@ -187,7 +186,7 @@ private final Path rewriteExtractSourcePath(Path p) { } private ToolDefinitionArtifactDescriptor getJreArtifactDescriptor(String jreVersion, String platform) { - var toolDefinitions = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(ToolDependency.JRE.getToolName()); + var toolDefinitions = ToolDefinitionsHelper.getToolDefinitionRootDescriptor("jre"); var jreVersionDescriptor = toolDefinitions.getVersion(jreVersion); var jreBinaryDescriptor = jreVersionDescriptor.getBinaries().get(platform); if ( jreBinaryDescriptor==null ) { throw new FcliSimpleException("No JRE found for platform "+platform); } diff --git a/gradle.properties b/gradle.properties index c7109f7f98a..6c5f4eda756 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ # needed, the corresponding project directory path can be obtained through the # getRefDir(ref) function. fcliAppRef=:fcli-core:fcli-app -fcliAgentRef=:fcli-core:fcli-agent +fcliAiAssistRef=:fcli-core:fcli-ai-assist fcliAviatorRef=:fcli-core:fcli-aviator fcliAviatorCommonRef=:fcli-core:fcli-aviator-common fcliCommonRef=:fcli-core:fcli-common From e6f3196f12f17598de0b871f288fb404e662f6af Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Thu, 21 May 2026 19:09:37 +0200 Subject: [PATCH 36/55] fix: `fcli sc-sast sensor list`: Include full sensor details independent of filtering options --- .../cli/cmd/SCSastSensorListCommand.java | 20 ++++++++++------ .../SCSastSensorCompatibleVersionHelper.java | 24 +++++++++++++++++++ .../sc_sast/i18n/SCSastMessages.properties | 14 +++++------ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/sensor/cli/cmd/SCSastSensorListCommand.java b/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/sensor/cli/cmd/SCSastSensorListCommand.java index 8e94ac0dda1..bd3e788fff9 100644 --- a/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/sensor/cli/cmd/SCSastSensorListCommand.java +++ b/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/sensor/cli/cmd/SCSastSensorListCommand.java @@ -52,7 +52,7 @@ protected IObjectNodeProducer getObjectNodeProducer(UnirestInstance unirest) { if (poolResolver.hasValue() || appVersionResolver.getAppVersionNameOrId() != null || latestOnly) { return StreamingObjectNodeProducer.builder() - .streamSupplier(() -> streamCompatibleVersions(unirest)) + .streamSupplier(() -> streamFilteredSensors(unirest)) .build(); } @@ -63,7 +63,7 @@ protected IObjectNodeProducer getObjectNodeProducer(UnirestInstance unirest) { @Override public boolean isSingular() { - return latestOnly; + return false; } private void validateMutualExclusivity() { @@ -72,10 +72,15 @@ private void validateMutualExclusivity() { } } - private Stream streamCompatibleVersions(UnirestInstance unirest) { - SCSastSensorCompatibleVersionHelper helper = buildHelper(unirest); - Stream stream = helper.streamCompatibleVersions(); - return latestOnly ? stream.limit(1) : stream; + private Stream streamFilteredSensors(UnirestInstance unirest) { + var helper = buildHelper(unirest); + Stream stream = helper.streamSensors(); + if (latestOnly) { + var latestVersion = helper.getLatestCompatibleVersion(); + stream = stream.filter(s -> latestVersion.equals( + s.path("compatibleClientVersion").asText())); + } + return stream; } private Stream streamAllSensors(UnirestInstance unirest) { @@ -85,7 +90,8 @@ private Stream streamAllSensors(UnirestInstance unirest) { .get("data"); return StreamSupport.stream(dataArray.spliterator(), false) - .map(node -> (ObjectNode) node); + .map(node -> SCSastSensorCompatibleVersionHelper + .enrichWithCompatibleVersion((ObjectNode) node)); } private SCSastSensorCompatibleVersionHelper buildHelper(UnirestInstance unirest) { diff --git a/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/sensor/helper/SCSastSensorCompatibleVersionHelper.java b/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/sensor/helper/SCSastSensorCompatibleVersionHelper.java index d975b2bba65..4e280ffe91f 100644 --- a/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/sensor/helper/SCSastSensorCompatibleVersionHelper.java +++ b/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/sensor/helper/SCSastSensorCompatibleVersionHelper.java @@ -45,6 +45,17 @@ public class SCSastSensorCompatibleVersionHelper { @Getter(lazy = true, value = AccessLevel.PRIVATE) private final JsonNode sensorData = fetchSensorData(); + /** + * Stream active sensor objects enriched with {@code compatibleClientVersion}. + * + * @return Stream of full sensor ObjectNodes with compatible version added + */ + public Stream streamSensors() { + return StreamSupport.stream(getSensorData().spliterator(), false) + .filter(this::isActiveSensor) + .map(sensor -> enrichWithCompatibleVersion((ObjectNode) sensor)); + } + /** * Stream all compatible versions sorted in descending order (newest first). * @@ -88,6 +99,19 @@ public String getLatestCompatibleVersion() { )); } + /** + * Enrich a sensor ObjectNode with {@code compatibleClientVersion} computed from its {@code scaVersion}. + */ + public static ObjectNode enrichWithCompatibleVersion(ObjectNode sensor) { + var scaVersion = sensor.path("scaVersion").asText(""); + if (StringUtils.isNotBlank(scaVersion)) { + var semVer = new SemVer(scaVersion); + sensor.put("compatibleClientVersion", + semVer.isProperSemver() ? semVer.getMajorMinor().orElse("") : ""); + } + return sensor; + } + private boolean isActiveSensor(JsonNode sensor) { JsonNode state = sensor.get("state"); return state != null && "ACTIVE".equalsIgnoreCase(state.asText()); diff --git a/fcli-core/fcli-sc-sast/src/main/resources/com/fortify/cli/sc_sast/i18n/SCSastMessages.properties b/fcli-core/fcli-sc-sast/src/main/resources/com/fortify/cli/sc_sast/i18n/SCSastMessages.properties index df99a99ed72..acc3d272804 100644 --- a/fcli-core/fcli-sc-sast/src/main/resources/com/fortify/cli/sc_sast/i18n/SCSastMessages.properties +++ b/fcli-core/fcli-sc-sast/src/main/resources/com/fortify/cli/sc_sast/i18n/SCSastMessages.properties @@ -96,13 +96,13 @@ fcli.sc-sast.scan-job.resolver.jobToken = Scan job token. fcli.sc-sast.sensor.usage.header = Manage ScanCentral SAST sensors. fcli.sc-sast.sensor.list.usage.header = List ScanCentral SAST sensors. fcli.sc-sast.sensor.list.usage.description = This command lists sensor information for all \ - available SanCentral SAST sensors. It calls the SSC API and as such requires an active SSC session. \ - When used without --pool, --appversion, or --latest-only options, it returns detailed sensor information. \ - When used with --pool, --appversion, or --latest-only, it returns compatible ScanCentral Client versions \ - based on the active sensors in the specified pool, application version's pool, or all pools. -fcli.sc-sast.sensor.list.pool = Sensor pool name or UUID to query for compatible client versions. -fcli.sc-sast.sensor.list.appversion = Application version name or id to select appropriate pool for compatible client versions. -fcli.sc-sast.sensor.list.latest-only = Show only the latest compatible client version instead of all sensors or versions. + available ScanCentral SAST sensors. It calls the SSC API and as such requires an active SSC session. \ + Use --pool or --appversion to filter sensors by pool, and --latest-only to show only sensors \ + running the latest compatible ScanCentral Client version. Output always includes full sensor \ + details along with the compatible client version derived from the sensor's SCA version. +fcli.sc-sast.sensor.list.pool = Filter sensors by the given sensor pool name or UUID. +fcli.sc-sast.sensor.list.appversion = Filter sensors by the pool mapped to the given application version. +fcli.sc-sast.sensor.list.latest-only = Show only sensors running the latest compatible client version. # fcli sc-sast sensor-pool fcli.sc-sast.sensor-pool.usage.header = Manage ScanCentral SAST sensor pools. From b8df9648a7c395600df6560cf7482b9b39a57e43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 23:05:44 +0000 Subject: [PATCH 37/55] =?UTF-8?q?Fix=20renamed=20agent=20mcp=20=E2=86=92?= =?UTF-8?q?=20ai-assist=20mcp=20command=20references,=20add=20AiAssistExte?= =?UTF-8?q?nsionsSpec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cmd/MCPServerStartDeprecatedCommand.java | 4 ++-- .../_common/MCPHttpServerTestHelper.groovy | 2 +- .../ftest/core/AiAssistExtensionsSpec.groovy | 21 +++++++++++++++++++ .../cli/ftest/core/MCPServerImportSpec.groovy | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java index af235f893e1..ba03a437133 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java @@ -35,11 +35,11 @@ public class MCPServerStartDeprecatedCommand extends AbstractRunnableCommand imp @Override public Integer call() { - var cmd = "fcli agent mcp start-stdio"; + var cmd = "fcli ai-assist mcp start-stdio"; if ( delegatedArgs != null && !delegatedArgs.isEmpty() ) { cmd += " " + String.join(" ", delegatedArgs); } - log.warn("The 'fcli util mcp-server start' command is deprecated; please use 'fcli agent mcp start-stdio'"); + log.warn("The 'fcli util mcp-server start' command is deprecated; please use 'fcli ai-assist mcp start-stdio'"); var result = FcliCommandExecutorFactory.builder() .cmd(cmd) .stdoutOutputType(OutputType.show) diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy index 296e061d06d..4819bac3151 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/_common/MCPHttpServerTestHelper.groovy @@ -36,7 +36,7 @@ class MCPHttpServerTestHelper { } static Process startHttpServer(HttpServerConfig config) { - def cmd = Fcli.buildExternalCommand(["agent", "mcp", "start-http", "--config", config.path.toString()]) + def cmd = Fcli.buildExternalCommand(["ai-assist", "mcp", "start-http", "--config", config.path.toString()]) def process = new ProcessBuilder(cmd) .redirectErrorStream(true) .start() diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy new file mode 100644 index 00000000000..93bf29e9045 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy @@ -0,0 +1,21 @@ +package com.fortify.cli.ftest.core + +import com.fortify.cli.ftest._common.Fcli +import com.fortify.cli.ftest._common.spec.FcliBaseSpec +import com.fortify.cli.ftest._common.spec.Prefix + +import spock.lang.Stepwise + +@Prefix("core.ai-assist.extensions") @Stepwise +class AiAssistExtensionsSpec extends FcliBaseSpec { + + def "list-installed"() { + when: + def result = Fcli.run("ai-assist extensions list-installed") + then: + verifyAll(result.stdout) { + size() == 1 + it[0].trim() == "No data" + } + } +} diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy index bcda14943c4..a481415e73a 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy @@ -26,7 +26,7 @@ class MCPServerImportSpec extends FcliBaseSpec { @Shared @TestResource("runtime/actions/server-global-vars.yaml") String globalVarsActionPath private McpSyncClient createMcpClient(String extraArgs = "") { - def serverArgs = ["util", "mcp-server", "start", "--import", importActionPath] + def serverArgs = ["ai-assist", "mcp", "start-stdio", "--import", importActionPath] if (extraArgs) { serverArgs.addAll(extraArgs.split(" ").toList()) } From 25f8d07bf6fa66ba11a85f7be3f9d257a22e36f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 23:39:16 +0000 Subject: [PATCH 38/55] chore: use deprecated fcli util mcp-server start in MCPServerImportSpec functional tests --- .../com/fortify/cli/ftest/core/MCPServerImportSpec.groovy | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy index a481415e73a..c0e80d5ee75 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/MCPServerImportSpec.groovy @@ -26,7 +26,11 @@ class MCPServerImportSpec extends FcliBaseSpec { @Shared @TestResource("runtime/actions/server-global-vars.yaml") String globalVarsActionPath private McpSyncClient createMcpClient(String extraArgs = "") { - def serverArgs = ["ai-assist", "mcp", "start-stdio", "--import", importActionPath] + // Using the deprecated 'fcli util mcp-server start' command instead of 'fcli ai-assist mcp start-stdio' + // because the deprecated command is a wrapper that calls the non-deprecated command, so this way + // we effectively test both the deprecated and non-deprecated command instead of testing only the + // non-deprecated command. + def serverArgs = ["util", "mcp-server", "start", "--import", importActionPath] if (extraArgs) { serverArgs.addAll(extraArgs.split(" ").toList()) } From b28f9bb97ed0f6f4688c1d775c23a7f72f2e85ea Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 22 May 2026 11:36:05 +0200 Subject: [PATCH 39/55] chore: Various fixes & improvements --- ...AiAssistExtensionsAssistantDescriptor.java | 4 + .../AiAssistExtensionsConditionEvaluator.java | 73 +++++++++---------- .../helper/ToolDefinitionsHelper.java | 3 + 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.java index 501e8fc1b79..2d6c31c6221 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.java @@ -27,6 +27,10 @@ public class AiAssistExtensionsAssistantDescriptor { @JsonProperty("display-name") private String displayName; + // Typed as Object to support recursive condition structures (maps for + // operators like any-of/all-of/not, strings for leaf values). This + // allows instanceof-based dispatch in AiAssistExtensionsConditionEvaluator + // and graceful warning on unknown condition types instead of parse failures. @JsonProperty("if") private Object ifCondition; private List targets; diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java index 48fd83d84e5..d8341897bfd 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java @@ -18,7 +18,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.SystemUtils; @@ -27,7 +26,7 @@ /** * Evaluates declarative conditions from the extensions-distribution.yaml descriptor. - * Supports simple conditions (dir-exists, command-exists) and + * Supports simple conditions (dir-exists, glob-exists, command-exists) and * logical operators (any-of, all-of, not). */ public final class AiAssistExtensionsConditionEvaluator { @@ -44,7 +43,7 @@ public boolean evaluate(Object condition) { if (condition instanceof Map map) { return evaluateMap((Map) map); } - LOG.warn("Unknown condition type: {}", condition.getClass().getName()); + LOG.warn("WARN: Unknown condition type: {}", condition.getClass().getName()); return false; } @@ -60,8 +59,6 @@ private boolean evaluateMap(Map map) { return evaluateGlobExists(value); case "command-exists": return evaluateCommandExists((String) value); - case "command-succeeds": - return evaluateCommandSucceeds((String) value); case "any-of": return evaluateAnyOf((java.util.List) value); case "all-of": @@ -69,7 +66,7 @@ private boolean evaluateMap(Map map) { case "not": return !evaluate(value); default: - LOG.warn("Unknown condition type '{}', treating as false", key); + LOG.warn("WARN: Unknown condition type '{}', treating as false", key); return false; } } @@ -138,42 +135,44 @@ private boolean evaluateGlobExists(Object value) { } } - private boolean evaluateCommandExists(String command) { - if (StringUtils.isBlank(command)) { return false; } - var cmd = SystemUtils.IS_OS_WINDOWS - ? new String[]{"where", command} - : new String[]{"which", command}; - return runProcessSucceeds(cmd, command); - } - /** - * Run an arbitrary command line and check for exit code 0. - * The value is split on whitespace. A 5-second timeout prevents hangs. + * Check if a command exists on the system PATH by scanning PATH directories + * for matching executables. On Windows, also checks PATHEXT extensions. + * Does not spawn external processes (no which/where). */ - private boolean evaluateCommandSucceeds(String commandLine) { - if (StringUtils.isBlank(commandLine)) { return false; } - var parts = commandLine.trim().split("\\s+"); - return runProcessSucceeds(parts, commandLine); + private boolean evaluateCommandExists(String command) { + if (StringUtils.isBlank(command)) { return false; } + var pathEnv = System.getenv("PATH"); + if (StringUtils.isBlank(pathEnv)) { return false; } + var pathSep = System.getProperty("path.separator"); + var dirs = pathEnv.split(pathSep); + // On Windows, try command as-is plus each PATHEXT extension + var extensions = SystemUtils.IS_OS_WINDOWS + ? getWindowsPathExtensions() + : new String[]{""}; + for (var dir : dirs) { + var dirPath = Path.of(dir); + for (var ext : extensions) { + var candidate = dirPath.resolve(command + ext); + if (Files.isRegularFile(candidate)) { + return true; + } + } + } + return false; } - private boolean runProcessSucceeds(String[] cmd, String label) { - try { - var pb = new ProcessBuilder(cmd); - pb.redirectErrorStream(true); - var process = pb.start(); - // Drain output to prevent blocking - process.getInputStream().transferTo(java.io.OutputStream.nullOutputStream()); - boolean finished = process.waitFor(5, TimeUnit.SECONDS); - if (!finished) { - process.destroyForcibly(); - LOG.debug("Command timed out: {}", label); - return false; - } - return process.exitValue() == 0; - } catch (IOException | InterruptedException e) { - LOG.debug("Error running command '{}': {}", label, e.getMessage()); - return false; + private static String[] getWindowsPathExtensions() { + var pathExt = System.getenv("PATHEXT"); + if (StringUtils.isBlank(pathExt)) { + return new String[]{"", ".exe", ".cmd", ".bat", ".com"}; } + // Prepend empty string so bare name is checked first + var exts = pathExt.split(";"); + var result = new String[exts.length + 1]; + result[0] = ""; + System.arraycopy(exts, 0, result, 1, exts.length); + return result; } private boolean evaluateAnyOf(List conditions) { diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java index b90e407f823..eb115b1c9e5 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java @@ -478,6 +478,9 @@ private static Path ensureStateZipExists() { try (InputStream is = FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP)) { Files.copy(is, DEFINITIONS_STATE_ZIP); } + // Set epoch timestamp so the age check treats this as stale and triggers + // a real update on the next 'tool definitions update' invocation. + Files.setLastModifiedTime(DEFINITIONS_STATE_ZIP, FileTime.fromMillis(0)); } return DEFINITIONS_STATE_ZIP; } From 66d4e174b5083569418545c0fc6c7d55d9c6966c Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 22 May 2026 13:20:02 +0200 Subject: [PATCH 40/55] chore: Refactor ai-assist extensions command structure --- .../cli/cmd/AiAssistExtensionsCommands.java | 3 +- .../cmd/AiAssistExtensionsInstallCommand.java | 87 -- ...va => AiAssistExtensionsSetupCommand.java} | 49 +- .../AiAssistExtensionsUninstallCommand.java | 13 +- ...iAssistExtensionsAssistantFilterMixin.java | 33 - .../AiAssistExtensionsConditionEvaluator.java | 4 +- ...sistExtensionsInstallationsDescriptor.java | 63 ++ .../helper/AiAssistExtensionsInstaller.java | 829 +++++++++++------- ... AiAssistExtensionsTargetDirManifest.java} | 29 +- .../i18n/AiAssistMessages.properties | 40 +- .../ftest/core/AiAssistExtensionsSpec.groovy | 120 ++- 11 files changed, 793 insertions(+), 477 deletions(-) delete mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsInstallCommand.java rename fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/{AiAssistExtensionsUpdateCommand.java => AiAssistExtensionsSetupCommand.java} (64%) delete mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/mixin/AiAssistExtensionsAssistantFilterMixin.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallationsDescriptor.java rename fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/{AiAssistExtensionsStateDescriptor.java => AiAssistExtensionsTargetDirManifest.java} (58%) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.java index 87d3c3e1ae3..14da749d533 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.java @@ -20,9 +20,8 @@ name = "extensions", aliases = {"ext"}, subcommands = { - AiAssistExtensionsInstallCommand.class, + AiAssistExtensionsSetupCommand.class, AiAssistExtensionsUninstallCommand.class, - AiAssistExtensionsUpdateCommand.class, AiAssistExtensionsListInstalledCommand.class, AiAssistExtensionsListVersionsCommand.class, AiAssistExtensionsListAssistantsCommand.class diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsInstallCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsInstallCommand.java deleted file mode 100644 index dbeb01921c5..00000000000 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsInstallCommand.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.ai_assist.extensions.cli.cmd; - -import java.util.Set; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.ai_assist.extensions.cli.mixin.AiAssistExtensionsAssistantFilterMixin; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; -import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; -import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; -import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; - -import lombok.Getter; -import picocli.CommandLine.Command; -import picocli.CommandLine.Mixin; -import picocli.CommandLine.Option; - -@Command(name = OutputHelperMixins.Install.CMD_NAME) -public class AiAssistExtensionsInstallCommand extends AbstractOutputCommand - implements IJsonNodeSupplier, IActionCommandResultSupplier { - @Mixin @Getter private OutputHelperMixins.Install outputHelper; - @Mixin private AiAssistExtensionsAssistantFilterMixin assistantFilter; - - @Option(names = {"-v", "--version"}, paramLabel = "", - descriptionKey = "fcli.ai-assist.extensions.version", - defaultValue = "latest") - private String version; - - @Option(names = {"-s", "--source"}, paramLabel = "", - descriptionKey = "fcli.ai-assist.extensions.source") - private String source; - - @Option(names = {"--dir"}, paramLabel = "", - descriptionKey = "fcli.ai-assist.extensions.dir") - private String customDir; - - @Option(names = {"--content-types"}, split = ",", paramLabel = "", - descriptionKey = "fcli.ai-assist.extensions.content-types") - private Set contentTypeFilter; - - @Option(names = {"--on-digest-mismatch"}, paramLabel = "", - descriptionKey = "fcli.ai-assist.extensions.on-digest-mismatch", - defaultValue = "fail") - private DigestMismatchAction onDigestMismatch; - - @Option(names = {"-y", "--confirm"}, - descriptionKey = "fcli.ai-assist.extensions.confirm") - private boolean confirm; - - @Option(names = {"--dry-run"}, - descriptionKey = "fcli.ai-assist.extensions.dry-run") - private boolean dryRun; - - @Override - public JsonNode getJsonNode() { - return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.install( - source, - version, - assistantFilter.getAssistants(), - assistantFilter.getExcludeAssistants(), - contentTypeFilter, - customDir, - onDigestMismatch, - dryRun)); - } - - @Override - public boolean isSingular() { return false; } - - @Override - public String getActionCommandResult() { return "INSTALLED"; } -} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUpdateCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java similarity index 64% rename from fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUpdateCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java index 603fba3f3b7..1321ba1cc57 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUpdateCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java @@ -15,9 +15,9 @@ import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.ai_assist.extensions.cli.mixin.AiAssistExtensionsAssistantFilterMixin; import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; +import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -25,15 +25,18 @@ import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import lombok.Getter; +import picocli.CommandLine.ArgGroup; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; -@Command(name = OutputHelperMixins.Update.CMD_NAME) -public class AiAssistExtensionsUpdateCommand extends AbstractOutputCommand +@Command(name = OutputHelperMixins.Setup.CMD_NAME) +public class AiAssistExtensionsSetupCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IActionCommandResultSupplier { - @Mixin @Getter private OutputHelperMixins.Update outputHelper; - @Mixin private AiAssistExtensionsAssistantFilterMixin assistantFilter; + @Mixin @Getter private OutputHelperMixins.Setup outputHelper; + + @ArgGroup(exclusive = true, multiplicity = "1") + private TargetSelectionGroup targetSelection; @Option(names = {"-v", "--version"}, paramLabel = "", descriptionKey = "fcli.ai-assist.extensions.version", @@ -44,10 +47,6 @@ public class AiAssistExtensionsUpdateCommand extends AbstractOutputCommand descriptionKey = "fcli.ai-assist.extensions.source") private String source; - @Option(names = {"--dir"}, paramLabel = "", - descriptionKey = "fcli.ai-assist.extensions.dir") - private String customDir; - @Option(names = {"--content-types"}, split = ",", paramLabel = "", descriptionKey = "fcli.ai-assist.extensions.content-types") private Set contentTypeFilter; @@ -67,21 +66,33 @@ public class AiAssistExtensionsUpdateCommand extends AbstractOutputCommand @Override public JsonNode getJsonNode() { + var customDir = targetSelection.customDir; + if (customDir != null && (contentTypeFilter == null || contentTypeFilter.isEmpty())) { + throw new FcliSimpleException("--content-types is required when using --dir"); + } return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.update( - source, - version, - assistantFilter.getAssistants(), - assistantFilter.getExcludeAssistants(), - contentTypeFilter, - customDir, - onDigestMismatch, - dryRun)); + AiAssistExtensionsInstaller.setup( + source, version, targetSelection.assistants, targetSelection.autoDetect, + contentTypeFilter, customDir, onDigestMismatch, dryRun)); } @Override public boolean isSingular() { return false; } @Override - public String getActionCommandResult() { return "UPDATED"; } + public String getActionCommandResult() { return "SETUP"; } + + static class TargetSelectionGroup { + @Option(names = {"--assistants"}, split = ",", paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.assistants") + Set assistants; + + @Option(names = {"--auto-detect"}, + descriptionKey = "fcli.ai-assist.extensions.auto-detect") + boolean autoDetect; + + @Option(names = {"--dir"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.dir") + String customDir; + } } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java index a7bacad56aa..d083141f39b 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java @@ -12,8 +12,9 @@ */ package com.fortify.cli.ai_assist.extensions.cli.cmd; +import java.util.Set; + import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.ai_assist.extensions.cli.mixin.AiAssistExtensionsAssistantFilterMixin; import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; @@ -30,7 +31,10 @@ public class AiAssistExtensionsUninstallCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IActionCommandResultSupplier { @Mixin @Getter private OutputHelperMixins.Uninstall outputHelper; - @Mixin private AiAssistExtensionsAssistantFilterMixin assistantFilter; + + @Option(names = {"--content-types"}, split = ",", paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.content-types") + private Set contentTypeFilter; @Option(names = {"-y", "--confirm"}, descriptionKey = "fcli.ai-assist.extensions.confirm") @@ -43,10 +47,7 @@ public class AiAssistExtensionsUninstallCommand extends AbstractOutputCommand @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.uninstall( - assistantFilter.getAssistants(), - assistantFilter.getExcludeAssistants(), - dryRun)); + AiAssistExtensionsInstaller.uninstall(contentTypeFilter, dryRun)); } @Override diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/mixin/AiAssistExtensionsAssistantFilterMixin.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/mixin/AiAssistExtensionsAssistantFilterMixin.java deleted file mode 100644 index f3b26bdbf5d..00000000000 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/mixin/AiAssistExtensionsAssistantFilterMixin.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.ai_assist.extensions.cli.mixin; - -import java.util.Set; - -import lombok.Getter; -import picocli.CommandLine.Option; - -/** - * Mixin providing --assistants and --exclude-assistants options. - */ -public class AiAssistExtensionsAssistantFilterMixin { - @Getter - @Option(names = {"--assistants"}, split = ",", paramLabel = "", - descriptionKey = "fcli.ai-assist.extensions.assistants") - private Set assistants; - - @Getter - @Option(names = {"--exclude-assistants"}, split = ",", paramLabel = "", - descriptionKey = "fcli.ai-assist.extensions.exclude-assistants") - private Set excludeAssistants; -} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java index d8341897bfd..3b67b7cfea1 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java @@ -35,11 +35,13 @@ public final class AiAssistExtensionsConditionEvaluator { public AiAssistExtensionsConditionEvaluator() {} /** - * Evaluate a condition object (may be a map with a single condition or operator). + * Evaluate a condition object (may be a map with a single condition or operator, + * or a boolean literal for unconditional true/false). */ @SuppressWarnings("unchecked") public boolean evaluate(Object condition) { if (condition == null) { return true; } + if (condition instanceof Boolean b) { return b; } if (condition instanceof Map map) { return evaluateMap((Map) map); } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallationsDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallationsDescriptor.java new file mode 100644 index 00000000000..f0bf39fec87 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallationsDescriptor.java @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Lightweight fcli state descriptor stored at + * {@code state/ai-assist/extensions/installations.json}. + * Records which assistants extensions were set up for and their resolved + * target directories. Used by {@code list-installed} and {@code uninstall} + * to work offline without needing the distribution descriptor. + */ +@JsonIgnoreProperties(ignoreUnknown=true) +@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) @Data +public class AiAssistExtensionsInstallationsDescriptor { + + /** + * Map from assistant ID to its installation entry. + */ + private Map assistants; + + public Map getAssistants() { + if (assistants == null) { assistants = new LinkedHashMap<>(); } + return assistants; + } + + @JsonIgnoreProperties(ignoreUnknown=true) + @Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data + public static class AssistantInstallation { + @JsonProperty("display-name") + private String displayName; + /** + * Map from content type (e.g., "skills", "agents") to resolved target directory path. + */ + private Map targets; + + public Map getTargets() { + if (targets == null) { targets = new LinkedHashMap<>(); } + return targets; + } + } +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java index e57472f89ba..e8b099541bf 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java @@ -13,16 +13,15 @@ package com.fortify.cli.ai_assist.extensions.helper; import java.io.IOException; -import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -31,8 +30,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstallationsDescriptor.AssistantInstallation; import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsStateDescriptor.FileEntry; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; @@ -42,12 +41,23 @@ import com.fortify.cli.tool.definitions.helper.ToolDefinitionsHelper; /** - * Core install/update/uninstall/list logic for AI assistant extensions. - * Output is grouped by (assistant, contentType, targetDir). + * Core setup/uninstall/list logic for AI assistant extensions. + *

    + * State is managed in two tiers: + *

      + *
    • fcli state ({@code state/ai-assist/extensions/installations.json}): + * lightweight registry of which assistants were set up and their resolved + * target directories. Used by {@code list-installed} and {@code uninstall} + * to work without the distribution descriptor.
    • + *
    • Target-dir manifest ({@code .fortify-extensions.json} in each target dir): + * records content type, version, and file list. Enables diff-based updates + * and state recovery after fcli state reset.
    • + *
    */ public final class AiAssistExtensionsInstaller { private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsInstaller.class); - private static final Path STATE_BASE_PATH = Path.of("state", "ai-assist", "extensions"); + private static final Path INSTALLATIONS_STATE_PATH = + Path.of("state", "ai-assist", "extensions", "installations.json"); private AiAssistExtensionsInstaller() {} @@ -62,57 +72,54 @@ public static ToolDefinitionVersionDescriptor resolveVersion(String version) { return getToolDefinitions().getVersionOrDefault(version); } - // ──────────────────────────── Install ──────────────────────────── + // ──────────────────────────── Setup (idempotent install/update) ──────────────────────────── - public static List install( + /** + * Idempotent setup: installs if new, updates if already present + * (adds new files, updates changed files, removes obsolete files). + * + * @param source local zip/dir override, or null for tool-definitions + * @param version version string (default "latest") + * @param assistants explicit assistant IDs, or null + * @param autoDetect true to auto-detect assistants + * @param contentTypeFilter filter by content type, or null for all + * @param customDir custom target directory (mutually exclusive with assistants/autoDetect) + * @param onDigestMismatch action on signature mismatch + * @param dryRun if true, plan only without executing + */ + public static List setup( String source, String version, - Set assistantFilter, Set excludeAssistants, + Set assistants, boolean autoDetect, Set contentTypeFilter, String customDir, DigestMismatchAction onDigestMismatch, boolean dryRun) { try (var sourceHandler = resolveSource(source, version, onDigestMismatch)) { var contentManifest = sourceHandler.readContentManifest(); - var distribution = AiAssistExtensionsSourceHandler - .readDistributionDescriptor(source == null); - var conditionEvaluator = new AiAssistExtensionsConditionEvaluator(); - var planContext = new AiAssistExtensionsInstallPlanContext(); - - var assistants = detectAssistants(distribution, conditionEvaluator, - assistantFilter, excludeAssistants); - var plan = buildInstallPlan(contentManifest, distribution, assistants, - sourceHandler, planContext, contentTypeFilter, customDir, - sourceHandler.getVersion()); - if (!dryRun) { - executePlan(plan, sourceHandler); + if (customDir != null) { + // --dir mode: install content types directly to custom directory, + // bypassing assistant selection entirely + var plan = buildCustomDirPlan(contentManifest, sourceHandler, + contentTypeFilter, customDir, sourceHandler.getVersion()); + if (!dryRun) { + executeSetupPlan(plan, sourceHandler); + } + return toOutputDescriptors(plan); } - return toOutputDescriptors(plan); - } - } - - // ──────────────────────────── Update ──────────────────────────── - - public static List update( - String source, String version, - Set assistantFilter, Set excludeAssistants, - Set contentTypeFilter, String customDir, - DigestMismatchAction onDigestMismatch, boolean dryRun) { - try (var sourceHandler = resolveSource(source, version, onDigestMismatch)) { - var contentManifest = sourceHandler.readContentManifest(); var distribution = AiAssistExtensionsSourceHandler .readDistributionDescriptor(source == null); - var conditionEvaluator = new AiAssistExtensionsConditionEvaluator(); + var selectedAssistants = selectAssistants(distribution, assistants, autoDetect); var planContext = new AiAssistExtensionsInstallPlanContext(); - - var assistants = detectAssistants(distribution, conditionEvaluator, - assistantFilter, excludeAssistants); - var plan = buildUpdatePlan(contentManifest, distribution, assistants, - sourceHandler, planContext, contentTypeFilter, customDir, + var plan = buildSetupPlan(contentManifest, distribution, selectedAssistants, + sourceHandler, planContext, contentTypeFilter, sourceHandler.getVersion()); + warnDuplicateContentDirs(distribution, selectedAssistants, contentTypeFilter); + if (!dryRun) { - executeUpdatePlan(plan, sourceHandler); + executeSetupPlan(plan, sourceHandler); + saveInstallationsState(selectedAssistants, distribution, plan); } return toOutputDescriptors(plan); } @@ -120,34 +127,78 @@ public static List update( // ──────────────────────────── Uninstall ──────────────────────────── + /** + * Uninstall extensions from all known target directories. Scans the union + * of dirs from the distribution descriptor and fcli state to find manifests. + * + * @param contentTypeFilter optional content type filter, or null for all + * @param dryRun if true, report only without deleting + */ public static List uninstall( - Set assistantFilter, Set excludeAssistants, - boolean dryRun) { - var stateEntries = loadAllStateDescriptors(); + Set contentTypeFilter, boolean dryRun) { + var targetDirs = collectAllKnownTargetDirs(); var results = new ArrayList(); - for (var state : stateEntries) { - if (!matchesFilter(state.getAssistantId(), assistantFilter, excludeAssistants)) { + for (var dir : targetDirs) { + var manifest = readTargetDirManifest(dir); + if (manifest == null) { continue; } + if (!matchesContentTypeFilter(manifest.getContentType(), contentTypeFilter)) { continue; } + + var files = manifest.getFiles() != null ? manifest.getFiles() : List.of(); if (!dryRun) { - for (var file : state.getFiles()) { - deleteTargetFile(Path.of(file.getTarget())); + for (var file : files) { + deleteTargetFile(dir.resolve(file)); } - deleteStateDescriptor(state.getAssistantId(), state.getContentType()); + deleteManifestFile(dir); } - results.add(stateToOutput(state, "REMOVED")); + results.add(AiAssistExtensionsOutputDescriptor.builder() + .contentType(manifest.getContentType()) + .targetDir(dir.toString()) + .fileCount(files.size()) + .sourceVersion(manifest.getVersion()) + .files(files.toArray(String[]::new)) + .filesString(String.join(", ", files)) + .actionResult("REMOVED") + .build()); + } + + if (!dryRun) { + clearInstallationsState(contentTypeFilter); } - if (!dryRun) { cleanEmptyStateDirs(); } return results; } // ──────────────────────────── List installed ──────────────────────────── public static List listInstalled() { - return loadAllStateDescriptors().stream() - .map(s -> stateToOutput(s, null)) - .toList(); + var installations = loadInstallationsState(); + var results = new ArrayList(); + + for (var entry : installations.getAssistants().entrySet()) { + var assistantId = entry.getKey(); + var installation = entry.getValue(); + for (var targetEntry : installation.getTargets().entrySet()) { + var contentType = targetEntry.getKey(); + var targetDir = Path.of(targetEntry.getValue()); + var manifest = readTargetDirManifest(targetDir); + var files = manifest != null && manifest.getFiles() != null + ? manifest.getFiles() : List.of(); + var version = manifest != null ? manifest.getVersion() : null; + results.add(AiAssistExtensionsOutputDescriptor.builder() + .assistant(installation.getDisplayName()) + .assistantId(assistantId) + .contentType(contentType) + .targetDir(targetDir.toString()) + .fileCount(files.size()) + .sourceVersion(version) + .files(files.toArray(String[]::new)) + .filesString(String.join(", ", files)) + .build()); + } + } + return results; } // ──────────────────────────── List versions ──────────────────────────── @@ -172,9 +223,7 @@ public static List listAssistants(b if (distribution.getAssistants() == null) { return Collections.emptyList(); } var conditionEvaluator = detect ? new AiAssistExtensionsConditionEvaluator() : null; - var installedState = loadAllStateDescriptors(); - var installedByAssistant = installedState.stream() - .collect(Collectors.groupingBy(AiAssistExtensionsStateDescriptor::getAssistantId)); + var installations = loadInstallationsState(); var result = new ArrayList(); for (var entry : distribution.getAssistants().entrySet()) { @@ -186,19 +235,22 @@ public static List listAssistants(b .toArray(String[]::new) : new String[0]; - String detected; - if (conditionEvaluator != null) { - detected = String.valueOf(conditionEvaluator.evaluate(assistant.getIfCondition())); - } else { - detected = "N/A"; + String detected = conditionEvaluator != null + ? String.valueOf(conditionEvaluator.evaluate(assistant.getIfCondition())) + : "N/A"; + + var assistantInstallation = installations.getAssistants().get(id); + var installed = assistantInstallation != null; + String installedVersion = null; + if (installed) { + // Read version from first target dir manifest + installedVersion = assistantInstallation.getTargets().values().stream() + .map(dir -> readTargetDirManifest(Path.of(dir))) + .filter(m -> m != null) + .map(AiAssistExtensionsTargetDirManifest::getVersion) + .findFirst().orElse(null); } - var assistantStates = installedByAssistant.getOrDefault(id, Collections.emptyList()); - var installed = !assistantStates.isEmpty(); - var installedVersion = assistantStates.stream() - .map(AiAssistExtensionsStateDescriptor::getSourceVersion) - .findFirst().orElse(null); - result.add(AiAssistExtensionsAssistantOutputDescriptor.builder() .id(id) .name(assistant.getDisplayName()) @@ -223,53 +275,55 @@ private static AiAssistExtensionsSourceHandler resolveSource( return AiAssistExtensionsSourceHandler.fromToolDefinitions(versionDesc, onDigestMismatch); } - // ──────────────────────────── Assistant detection ──────────────────────────── + // ──────────────────────────── Assistant selection ──────────────────────────── - private static Map detectAssistants( + private static Map selectAssistants( AiAssistExtensionsDistributionDescriptor distribution, - AiAssistExtensionsConditionEvaluator evaluator, - Set assistantFilter, Set excludeAssistants) { + Set explicitAssistants, boolean autoDetect) { var result = new LinkedHashMap(); if (distribution.getAssistants() == null) { return result; } - for (var entry : distribution.getAssistants().entrySet()) { - var id = entry.getKey(); - var assistant = entry.getValue(); - if (!matchesFilter(id, assistantFilter, excludeAssistants)) { continue; } - boolean explicitlySelected = assistantFilter != null && !assistantFilter.isEmpty(); - if (explicitlySelected || evaluator.evaluate(assistant.getIfCondition())) { + if (explicitAssistants != null && !explicitAssistants.isEmpty()) { + for (var id : explicitAssistants) { + var assistant = distribution.getAssistants().get(id); + if (assistant == null) { + throw new FcliSimpleException( + "Unknown assistant: " + id + ". Available: " + + String.join(", ", distribution.getAssistants().keySet())); + } result.put(id, assistant); } + } else if (autoDetect) { + var evaluator = new AiAssistExtensionsConditionEvaluator(); + for (var entry : distribution.getAssistants().entrySet()) { + if (evaluator.evaluate(entry.getValue().getIfCondition())) { + result.put(entry.getKey(), entry.getValue()); + } + } } return result; } - private static boolean matchesFilter(String id, Set include, Set exclude) { - if (include != null && !include.isEmpty() && !include.contains(id)) { return false; } - if (exclude != null && exclude.contains(id)) { return false; } - return true; - } - // ──────────────────────────── Internal plan entry ──────────────────────────── - /** - * Internal per-file plan entry used during plan construction. - * Aggregated into grouped output descriptors after planning. - */ private record PlanEntry( String assistant, String assistantId, String contentType, - String targetDir, String sourceFile, String targetPath, - String sourceVersion, String action) {} + String targetDir, String sourceFile, String targetRelPath, + String targetAbsPath, String sourceVersion, String action) {} - // ──────────────────────────── Install plan ──────────────────────────── + // ──────────────────────────── Setup plan ──────────────────────────── - private static List buildInstallPlan( + /** + * Build a setup plan that is idempotent: installs new files, updates changed + * files, removes obsolete files, and reports unchanged files. + */ + private static List buildSetupPlan( AiAssistExtensionsContentManifestDescriptor contentManifest, AiAssistExtensionsDistributionDescriptor distribution, Map assistants, AiAssistExtensionsSourceHandler sourceHandler, AiAssistExtensionsInstallPlanContext planContext, - Set contentTypeFilter, String customDir, + Set contentTypeFilter, String sourceVersion) { var plan = new ArrayList(); @@ -282,9 +336,7 @@ private static List buildInstallPlan( var contentType = target.getContentType(); if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } - var resolvedDirs = customDir != null - ? List.of(Path.of(customDir).toAbsolutePath().normalize()) - : AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); + var resolvedDirs = AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); if (resolvedDirs.isEmpty()) { continue; } var coveredDir = planContext.findCoveredDir(resolvedDirs, contentType); @@ -295,85 +347,125 @@ private static List buildInstallPlan( planContext.markCovered(resolvedDir, contentType); } + if (isExisting) { + // Another assistant already handles this dir in this run + addExistingEntries(plan, assistant, assistantId, contentType, + resolvedDir, contentManifest, target, sourceHandler, sourceVersion); + continue; + } + + // Read existing manifest from target dir for diff + var existingManifest = readTargetDirManifest(resolvedDir); + var existingFiles = existingManifest != null && existingManifest.getFiles() != null + ? new HashSet<>(existingManifest.getFiles()) : Set.of(); + var sourceFiles = discoverSourceFiles(contentManifest, target, sourceHandler); + var handledRelPaths = new HashSet(); for (var sourceFile : sourceFiles) { var targetRelPath = getTargetRelativePath(contentManifest, target, sourceFile); - var targetPath = resolvedDir.resolve(targetRelPath).toString(); + var targetAbsPath = resolvedDir.resolve(targetRelPath).toString(); + handledRelPaths.add(targetRelPath); + + String action; + if (!existingFiles.contains(targetRelPath)) { + action = "INSTALLED"; + } else if (hasFileChanged(sourceHandler, sourceFile, Path.of(targetAbsPath))) { + action = "UPDATED"; + } else { + action = "UNCHANGED"; + } plan.add(new PlanEntry( assistant.getDisplayName(), assistantId, contentType, - resolvedDir.toString(), sourceFile, targetPath, - sourceVersion, isExisting ? "EXISTING" : "INSTALLED")); + resolvedDir.toString(), sourceFile, targetRelPath, + targetAbsPath, sourceVersion, action)); + } + + // Files in existing manifest but not in source → REMOVED + for (var existingFile : existingFiles) { + if (!handledRelPaths.contains(existingFile)) { + plan.add(new PlanEntry( + assistant.getDisplayName(), assistantId, contentType, + resolvedDir.toString(), null, existingFile, + resolvedDir.resolve(existingFile).toString(), + sourceVersion, "REMOVED")); + } } } } return plan; } - // ──────────────────────────── Update plan ──────────────────────────── + private static void addExistingEntries( + List plan, + AiAssistExtensionsAssistantDescriptor assistant, String assistantId, + String contentType, Path resolvedDir, + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsTargetDescriptor target, + AiAssistExtensionsSourceHandler sourceHandler, String sourceVersion) { + var sourceFiles = discoverSourceFiles(contentManifest, target, sourceHandler); + for (var sourceFile : sourceFiles) { + var targetRelPath = getTargetRelativePath(contentManifest, target, sourceFile); + plan.add(new PlanEntry( + assistant.getDisplayName(), assistantId, contentType, + resolvedDir.toString(), sourceFile, targetRelPath, + resolvedDir.resolve(targetRelPath).toString(), + sourceVersion, "EXISTING")); + } + } - private static List buildUpdatePlan( + // ──────────────────────────── Custom-dir plan ──────────────────────────── + + /** + * Build a setup plan for --dir mode: installs content types directly to + * a custom directory, bypassing assistant selection. Content is discovered + * from the content manifest without relying on assistant-specific config. + */ + private static List buildCustomDirPlan( AiAssistExtensionsContentManifestDescriptor contentManifest, - AiAssistExtensionsDistributionDescriptor distribution, - Map assistants, AiAssistExtensionsSourceHandler sourceHandler, - AiAssistExtensionsInstallPlanContext planContext, Set contentTypeFilter, String customDir, String sourceVersion) { - var installPlan = buildInstallPlan(contentManifest, distribution, assistants, - sourceHandler, planContext, contentTypeFilter, customDir, sourceVersion); - - var existingState = loadAllStateDescriptors(); - // Build lookup: "assistantId:contentType:sourceFile" → target path - var existingByKey = new LinkedHashMap(); - for (var state : existingState) { - for (var file : state.getFiles()) { - existingByKey.put(state.getAssistantId() + ":" + state.getContentType() - + ":" + file.getSource(), file.getTarget()); - } - } - var plan = new ArrayList(); - var handledKeys = new java.util.HashSet(); - - for (var entry : installPlan) { - var key = entry.assistantId() + ":" + entry.contentType() - + ":" + entry.sourceFile(); - handledKeys.add(key); - - if ("EXISTING".equals(entry.action())) { - plan.add(entry); - continue; - } - - var existingTarget = existingByKey.get(key); - if (existingTarget == null) { - plan.add(new PlanEntry(entry.assistant(), entry.assistantId(), - entry.contentType(), entry.targetDir(), entry.sourceFile(), - entry.targetPath(), sourceVersion, "INSTALLED")); - } else if (hasFileChanged(sourceHandler, entry.sourceFile(), Path.of(existingTarget))) { - plan.add(new PlanEntry(entry.assistant(), entry.assistantId(), - entry.contentType(), entry.targetDir(), entry.sourceFile(), - entry.targetPath(), sourceVersion, "UPDATED")); - } else { - plan.add(new PlanEntry(entry.assistant(), entry.assistantId(), - entry.contentType(), entry.targetDir(), entry.sourceFile(), - entry.targetPath(), sourceVersion, "UNCHANGED")); + var resolvedDir = Path.of(customDir).toAbsolutePath().normalize(); + if (contentManifest.getContentTypes() == null) { return plan; } + + for (var ctEntry : contentManifest.getContentTypes().entrySet()) { + var contentType = ctEntry.getKey(); + if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } + + var ctDesc = ctEntry.getValue(); + var existingManifest = readTargetDirManifest(resolvedDir); + var existingFiles = existingManifest != null && existingManifest.getFiles() != null + ? new HashSet<>(existingManifest.getFiles()) : Set.of(); + + var sourceFiles = discoverSourceFilesForContentType(ctDesc, sourceHandler); + var handledRelPaths = new HashSet(); + for (var sourceFile : sourceFiles) { + var targetRelPath = getTargetRelativePathForContentType(ctDesc, sourceFile); + var targetAbsPath = resolvedDir.resolve(targetRelPath).toString(); + handledRelPaths.add(targetRelPath); + + String action; + if (!existingFiles.contains(targetRelPath)) { + action = "INSTALLED"; + } else if (hasFileChanged(sourceHandler, sourceFile, Path.of(targetAbsPath))) { + action = "UPDATED"; + } else { + action = "UNCHANGED"; + } + plan.add(new PlanEntry( + null, null, contentType, + resolvedDir.toString(), sourceFile, targetRelPath, + targetAbsPath, sourceVersion, action)); } - } - // Files in state but not in source → REMOVED - for (var state : existingState) { - if (!matchesFilter(state.getAssistantId(), - assistants.isEmpty() ? null : assistants.keySet(), null)) { - continue; - } - for (var file : state.getFiles()) { - var key = state.getAssistantId() + ":" + state.getContentType() - + ":" + file.getSource(); - if (!handledKeys.contains(key)) { - plan.add(new PlanEntry(state.getAssistant(), state.getAssistantId(), - state.getContentType(), state.getTargetDir(), file.getSource(), - file.getTarget(), sourceVersion, "REMOVED")); + for (var existingFile : existingFiles) { + if (!handledRelPaths.contains(existingFile)) { + plan.add(new PlanEntry( + null, null, contentType, + resolvedDir.toString(), null, existingFile, + resolvedDir.resolve(existingFile).toString(), + sourceVersion, "REMOVED")); } } } @@ -382,12 +474,7 @@ private static List buildUpdatePlan( // ──────────────────────────── Plan → grouped output ──────────────────────────── - /** - * Aggregate per-file plan entries into grouped output descriptors, - * one per (assistantId, contentType, targetDir, action). - */ private static List toOutputDescriptors(List plan) { - // Group by (assistantId, contentType, targetDir, action) var groups = plan.stream().collect(Collectors.groupingBy( e -> e.assistantId() + "\0" + e.contentType() + "\0" + e.targetDir() + "\0" + e.action(), LinkedHashMap::new, Collectors.toList())); @@ -396,7 +483,7 @@ private static List toOutputDescriptors(List for (var entries : groups.values()) { var first = entries.get(0); var files = entries.stream() - .map(e -> targetRelativePath(e.targetDir(), e.targetPath())) + .map(PlanEntry::targetRelPath) .toArray(String[]::new); result.add(AiAssistExtensionsOutputDescriptor.builder() .assistant(first.assistant()) @@ -413,37 +500,6 @@ private static List toOutputDescriptors(List return result; } - private static String targetRelativePath(String targetDir, String targetPath) { - var dirPath = Path.of(targetDir); - var fullPath = Path.of(targetPath); - if (fullPath.startsWith(dirPath)) { - return dirPath.relativize(fullPath).toString(); - } - return targetPath; - } - - // ──────────────────────────── State → output ──────────────────────────── - - private static AiAssistExtensionsOutputDescriptor stateToOutput( - AiAssistExtensionsStateDescriptor state, String action) { - var files = state.getFiles() != null - ? state.getFiles().stream() - .map(f -> targetRelativePath(state.getTargetDir(), f.getTarget())) - .toArray(String[]::new) - : new String[0]; - return AiAssistExtensionsOutputDescriptor.builder() - .assistant(state.getAssistant()) - .assistantId(state.getAssistantId()) - .contentType(state.getContentType()) - .targetDir(state.getTargetDir()) - .fileCount(files.length) - .sourceVersion(state.getSourceVersion()) - .files(files) - .filesString(String.join(", ", files)) - .actionResult(action) - .build(); - } - // ──────────────────────────── Content discovery ──────────────────────────── private static List discoverSourceFiles( @@ -557,6 +613,83 @@ private static String getTargetRelativePath( return sourceFile; } + // ──────────────────────────── Custom-dir content discovery ──────────────────────────── + + /** + * Discover source files for a content type without assistant-specific target config. + * For directory/files modes, behaves identically to the target-aware variant. + * For explicit mode, discovers all entries defined in the content type. + */ + private static List discoverSourceFilesForContentType( + AiAssistExtensionsContentTypeDescriptor ctDesc, + AiAssistExtensionsSourceHandler sourceHandler) { + var discoverMode = ctDesc.getDiscover(); + + if ("explicit".equals(discoverMode)) { + return discoverAllExplicitEntries(ctDesc, sourceHandler); + } + + var sourceDir = ctDesc.getSourceDir(); + if (sourceDir == null || !sourceHandler.exists(sourceDir)) { + return Collections.emptyList(); + } + if ("directory".equals(discoverMode)) { + return discoverDirectoryEntries(sourceDir, ctDesc.getEntryMarker(), sourceHandler); + } else if ("files".equals(discoverMode)) { + return discoverFileEntries(sourceDir, ctDesc.getFilePattern(), sourceHandler); + } + return Collections.emptyList(); + } + + /** + * Discover all explicit entries defined in the content type descriptor, + * without filtering by assistant-specific source-entries. + */ + private static List discoverAllExplicitEntries( + AiAssistExtensionsContentTypeDescriptor ctDesc, + AiAssistExtensionsSourceHandler sourceHandler) { + var entriesMap = ctDesc.getEntries(); + if (entriesMap == null) { return Collections.emptyList(); } + var result = new ArrayList(); + for (var entryPath : entriesMap.values()) { + if (entryPath != null && sourceHandler.exists(entryPath)) { + var resolvedPath = sourceHandler.getExtractedDir().resolve(entryPath); + if (Files.isDirectory(resolvedPath)) { + sourceHandler.listFiles(entryPath).forEach(f -> result.add(f.toString())); + } else { + result.add(entryPath); + } + } + } + return result; + } + + /** + * Compute target-relative path for a source file using only the content type + * descriptor (no assistant target). For explicit mode, strips entry path prefix. + */ + private static String getTargetRelativePathForContentType( + AiAssistExtensionsContentTypeDescriptor ctDesc, String sourceFile) { + var discoverMode = ctDesc.getDiscover(); + if ("explicit".equals(discoverMode)) { + var entriesMap = ctDesc.getEntries(); + if (entriesMap != null) { + for (var entryPath : entriesMap.values()) { + if (entryPath != null && sourceFile.startsWith(entryPath + "/")) { + return sourceFile.substring(entryPath.length() + 1); + } else if (sourceFile.equals(entryPath)) { + return Path.of(sourceFile).getFileName().toString(); + } + } + } + return Path.of(sourceFile).getFileName().toString(); + } + if (ctDesc.getSourceDir() != null && sourceFile.startsWith(ctDesc.getSourceDir() + "/")) { + return sourceFile.substring(ctDesc.getSourceDir().length() + 1); + } + return sourceFile; + } + private static boolean matchesGlob(String filename, String glob) { var regex = glob.replace(".", "\\.").replace("*", ".*"); return filename.matches(regex); @@ -564,30 +697,35 @@ private static boolean matchesGlob(String filename, String glob) { // ──────────────────────────── Plan execution ──────────────────────────── - private static void executePlan(List plan, + private static void executeSetupPlan(List plan, AiAssistExtensionsSourceHandler sourceHandler) { - for (var entry : plan) { - if ("INSTALLED".equals(entry.action())) { - installFile(sourceHandler, entry); + // Group by target dir to write one manifest per dir + var byTargetDir = plan.stream() + .filter(e -> !"EXISTING".equals(e.action())) + .collect(Collectors.groupingBy(PlanEntry::targetDir, + LinkedHashMap::new, Collectors.toList())); + + for (var dirEntries : byTargetDir.values()) { + for (var entry : dirEntries) { + switch (entry.action()) { + case "INSTALLED", "UPDATED" -> installFile(sourceHandler, entry); + case "REMOVED" -> deleteTargetFile(Path.of(entry.targetAbsPath())); + } } - } - savePlanState(plan); - } - private static void executeUpdatePlan(List plan, - AiAssistExtensionsSourceHandler sourceHandler) { - for (var entry : plan) { - switch (entry.action()) { - case "INSTALLED", "UPDATED" -> installFile(sourceHandler, entry); - case "REMOVED" -> deleteTargetFile(Path.of(entry.targetPath())); - } + // Write manifest for this target dir + var first = dirEntries.get(0); + var installedFiles = dirEntries.stream() + .filter(e -> !"REMOVED".equals(e.action())) + .map(PlanEntry::targetRelPath) + .toList(); + writeTargetDirManifest(Path.of(first.targetDir()), first.contentType(), + first.sourceVersion(), installedFiles); } - savePlanState(plan); - cleanEmptyStateDirs(); } private static void installFile(AiAssistExtensionsSourceHandler sourceHandler, PlanEntry entry) { - var targetPath = Path.of(entry.targetPath()); + var targetPath = Path.of(entry.targetAbsPath()); var sourceBytes = sourceHandler.readFileBytes(entry.sourceFile()); if (sourceBytes == null) { throw new FcliSimpleException("Source file not found: " + entry.sourceFile()); @@ -600,58 +738,197 @@ private static void installFile(AiAssistExtensionsSourceHandler sourceHandler, P } } - // ──────────────────────────── State management ──────────────────────────── + // ──────────────────────────── Target dir manifest ──────────────────────────── + + private static void writeTargetDirManifest(Path targetDir, String contentType, + String version, List files) { + var manifest = AiAssistExtensionsTargetDirManifest.builder() + .schemaVersion(1) + .contentType(contentType) + .version(version) + .timestamp(Instant.now().toString()) + .files(files) + .build(); + var manifestPath = targetDir.resolve(AiAssistExtensionsTargetDirManifest.MANIFEST_FILENAME); + try { + Files.createDirectories(targetDir); + var json = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsString(manifest); + Files.writeString(manifestPath, json); + } catch (IOException e) { + throw new FcliTechnicalException("Error writing manifest: " + manifestPath, e); + } + } + + static AiAssistExtensionsTargetDirManifest readTargetDirManifest(Path targetDir) { + var manifestPath = targetDir.resolve(AiAssistExtensionsTargetDirManifest.MANIFEST_FILENAME); + if (!Files.isRegularFile(manifestPath)) { return null; } + try { + var content = Files.readString(manifestPath); + return JsonHelper.getObjectMapper() + .readValue(content, AiAssistExtensionsTargetDirManifest.class); + } catch (IOException e) { + LOG.warn("Error reading manifest: {}", manifestPath, e); + return null; + } + } + + private static void deleteManifestFile(Path targetDir) { + var manifestPath = targetDir.resolve(AiAssistExtensionsTargetDirManifest.MANIFEST_FILENAME); + try { + Files.deleteIfExists(manifestPath); + } catch (IOException e) { + LOG.warn("Error deleting manifest: {}", manifestPath, e); + } + } + + // ──────────────────────────── Installations state (fcli state) ──────────────────────────── + + private static AiAssistExtensionsInstallationsDescriptor loadInstallationsState() { + var desc = FcliDataHelper.readFile(INSTALLATIONS_STATE_PATH, + AiAssistExtensionsInstallationsDescriptor.class, false); + return desc != null ? desc : new AiAssistExtensionsInstallationsDescriptor(); + } + + private static void saveInstallationsState( + Map selectedAssistants, + AiAssistExtensionsDistributionDescriptor distribution, + List plan) { + var existing = loadInstallationsState(); + + // Build target dir map from plan for each assistant + var planTargets = plan.stream() + .filter(e -> !"EXISTING".equals(e.action()) && !"REMOVED".equals(e.action())) + .collect(Collectors.groupingBy(PlanEntry::assistantId, + LinkedHashMap::new, Collectors.toList())); + + for (var entry : selectedAssistants.entrySet()) { + var assistantId = entry.getKey(); + var assistant = entry.getValue(); + var entries = planTargets.getOrDefault(assistantId, List.of()); + + var targets = new LinkedHashMap(); + for (var planEntry : entries) { + targets.putIfAbsent(planEntry.contentType(), planEntry.targetDir()); + } + // Also include targets from EXISTING entries (shared dirs) + plan.stream() + .filter(e -> "EXISTING".equals(e.action()) && e.assistantId().equals(assistantId)) + .forEach(e -> targets.putIfAbsent(e.contentType(), e.targetDir())); + + if (!targets.isEmpty()) { + existing.getAssistants().put(assistantId, AssistantInstallation.builder() + .displayName(assistant.getDisplayName()) + .targets(targets) + .build()); + } + } + + FcliDataHelper.saveFile(INSTALLATIONS_STATE_PATH, existing, true); + } + + private static void clearInstallationsState(Set contentTypeFilter) { + if (contentTypeFilter == null || contentTypeFilter.isEmpty()) { + FcliDataHelper.deleteFile(INSTALLATIONS_STATE_PATH, false); + return; + } + // Partial clear: remove only matching content types from each assistant + var state = loadInstallationsState(); + var toRemove = new ArrayList(); + for (var entry : state.getAssistants().entrySet()) { + entry.getValue().getTargets().keySet().removeAll(contentTypeFilter); + if (entry.getValue().getTargets().isEmpty()) { + toRemove.add(entry.getKey()); + } + } + toRemove.forEach(state.getAssistants()::remove); + if (state.getAssistants().isEmpty()) { + FcliDataHelper.deleteFile(INSTALLATIONS_STATE_PATH, false); + } else { + FcliDataHelper.saveFile(INSTALLATIONS_STATE_PATH, state, true); + } + } + + // ──────────────────────────── Target dir collection ──────────────────────────── /** - * Save grouped state descriptors from a plan. - * Groups plan entries by (assistantId, contentType) and writes one state file each. - * Entries with action REMOVED cause their state file to be deleted. + * Collect all known target directories from both the distribution descriptor + * (if available) and the fcli installations state. */ - private static void savePlanState(List plan) { - // Group by (assistantId, contentType) - var groups = plan.stream().collect(Collectors.groupingBy( - e -> e.assistantId() + "\0" + e.contentType(), - LinkedHashMap::new, Collectors.toList())); + private static Set collectAllKnownTargetDirs() { + var dirs = new LinkedHashSet(); - for (var groupEntries : groups.values()) { - var first = groupEntries.get(0); - // Collect non-removed files - var files = groupEntries.stream() - .filter(e -> !"REMOVED".equals(e.action()) && !"EXISTING".equals(e.action())) - .map(e -> FileEntry.builder() - .source(e.sourceFile()) - .target(e.targetPath()) - .build()) - .toList(); + // From distribution descriptor (if tool-definitions available) + try { + var distribution = AiAssistExtensionsSourceHandler.readDistributionDescriptor(true); + if (distribution.getAssistants() != null) { + for (var assistant : distribution.getAssistants().values()) { + if (assistant.getTargets() == null) { continue; } + for (var target : assistant.getTargets()) { + dirs.addAll(AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs())); + } + } + } + } catch (Exception e) { + LOG.debug("Distribution descriptor not available for uninstall scan", e); + } - if (files.isEmpty()) { - // All removed or all existing — delete state - deleteStateDescriptor(first.assistantId(), first.contentType()); - } else { - var state = AiAssistExtensionsStateDescriptor.builder() - .assistant(first.assistant()) - .assistantId(first.assistantId()) - .contentType(first.contentType()) - .targetDir(first.targetDir()) - .sourceVersion(first.sourceVersion()) - .timestamp(Instant.now().toString()) - .files(files) - .build(); - var relativePath = STATE_BASE_PATH - .resolve(first.assistantId()) - .resolve(first.contentType() + ".json"); - FcliDataHelper.saveFile(relativePath, state, true); + // From fcli state + var installations = loadInstallationsState(); + for (var installation : installations.getAssistants().values()) { + for (var dir : installation.getTargets().values()) { + dirs.add(Path.of(dir)); } } + + return dirs; } - private static void deleteStateDescriptor(String assistantId, String contentType) { - var relativePath = STATE_BASE_PATH - .resolve(assistantId) - .resolve(contentType + ".json"); - FcliDataHelper.deleteFile(relativePath, false); + // ──────────────────────────── Duplicate content warning ──────────────────────────── + + /** + * Warn when a content type is present in multiple directories that an + * assistant reads from, which may cause duplicate entries in that assistant. + */ + private static void warnDuplicateContentDirs( + AiAssistExtensionsDistributionDescriptor distribution, + Map selectedAssistants, + Set contentTypeFilter) { + if (distribution.getAssistants() == null) { return; } + + for (var entry : distribution.getAssistants().entrySet()) { + var assistantId = entry.getKey(); + var assistant = entry.getValue(); + if (assistant.getTargets() == null) { continue; } + + for (var target : assistant.getTargets()) { + var contentType = target.getContentType(); + if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } + + var resolvedDirs = AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); + var dirsWithManifest = resolvedDirs.stream() + .filter(dir -> readTargetDirManifest(dir) != null + || selectedAssistants.containsKey(assistantId)) + .filter(dir -> { + var m = readTargetDirManifest(dir); + return m != null && contentType.equals(m.getContentType()); + }) + .toList(); + + if (dirsWithManifest.size() > 1) { + LOG.warn("Content type '{}' exists in multiple directories accessible by {}: {}. " + + "This may cause duplicate entries in the assistant. Consider running " + + "'uninstall' to clean up before re-running 'setup'.", + contentType, assistant.getDisplayName(), + dirsWithManifest.stream().map(Path::toString) + .collect(Collectors.joining(", "))); + } + } + } } + // ──────────────────────────── File operations ──────────────────────────── + private static void deleteTargetFile(Path targetPath) { try { Files.deleteIfExists(targetPath); @@ -671,54 +948,6 @@ private static void deleteTargetFile(Path targetPath) { } } - static List loadAllStateDescriptors() { - var result = new ArrayList(); - var basePath = FcliDataHelper.getFcliHomePath().resolve(STATE_BASE_PATH); - if (!Files.isDirectory(basePath)) { return result; } - - try { - Files.walkFileTree(basePath, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - if (file.toString().endsWith(".json")) { - try { - var content = Files.readString(file); - var desc = JsonHelper.getObjectMapper() - .readValue(content, AiAssistExtensionsStateDescriptor.class); - result.add(desc); - } catch (IOException e) { - LOG.warn("Error reading state file: {}", file, e); - } - } - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - LOG.warn("Error walking state directory: {}", basePath, e); - } - return result; - } - - private static void cleanEmptyStateDirs() { - var basePath = FcliDataHelper.getFcliHomePath().resolve(STATE_BASE_PATH); - if (!Files.isDirectory(basePath)) { return; } - try { - Files.walkFileTree(basePath, new SimpleFileVisitor() { - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { - if (!dir.equals(basePath)) { - try (var stream = Files.list(dir)) { - if (stream.findAny().isEmpty()) { Files.delete(dir); } - } - } - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - LOG.debug("Error cleaning empty state dirs", e); - } - } - private static boolean hasFileChanged( AiAssistExtensionsSourceHandler sourceHandler, String sourceFile, Path targetPath) { if (!Files.isRegularFile(targetPath)) { return true; } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.java similarity index 58% rename from fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateDescriptor.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.java index 977bcac58b9..1885334ca40 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateDescriptor.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.java @@ -14,6 +14,7 @@ import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.formkiq.graalvm.annotations.Reflectable; @@ -23,23 +24,21 @@ import lombok.NoArgsConstructor; /** - * State descriptor stored per (assistantId, contentType) under the fcli state directory. - * Tracks what was installed, where, and from which source version. + * Manifest stored as {@code .fortify-extensions.json} in each target directory. + * Tracks what was installed in that directory (files, version, content type) + * without recording which assistants use this directory. This allows + * recovery of installation state even if the fcli state is reset. */ +@JsonIgnoreProperties(ignoreUnknown=true) @Reflectable @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) @Data -public class AiAssistExtensionsStateDescriptor { - private String assistant; - private String assistantId; +public class AiAssistExtensionsTargetDirManifest { + public static final String MANIFEST_FILENAME = ".fortify-extensions.json"; + + @JsonProperty("schema-version") + private int schemaVersion; + @JsonProperty("content-type") private String contentType; - private String targetDir; - private String sourceVersion; - @JsonProperty("timestamp") + private String version; private String timestamp; - private List files; - - @Reflectable @NoArgsConstructor @AllArgsConstructor @Builder @Data - public static class FileEntry { - private String source; - private String target; - } + private List files; } diff --git a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties index 6fa988d3f38..4412750b087 100644 --- a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties @@ -3,16 +3,30 @@ fcli.ai-assist.usage.description = Manage AI-related functionality like MCP serv # fcli ai-assist extensions fcli.ai-assist.extensions.usage.header = (PREVIEW) Manage Fortify extensions for AI coding assistants -fcli.ai-assist.extensions.usage.description = Install, update, uninstall, or check status of Fortify extensions (skills, agents, plugins) for AI coding assistants like Claude Code, GitHub Copilot, OpenAI Codex, and Gemini CLI. -fcli.ai-assist.extensions.install.usage.header = Install Fortify extensions to detected coding assistants -fcli.ai-assist.extensions.install.usage.description = Download and install Fortify extensions (skills, agents, plugins) to detected AI coding assistants. By default, extensions are downloaded from the official Fortify tool definitions. Use --source to specify a local zip or directory. -fcli.ai-assist.extensions.install.output.table.args = assistant,contentType,targetDir,fileCount,__action__ -fcli.ai-assist.extensions.uninstall.usage.header = Remove installed Fortify extensions from coding assistants -fcli.ai-assist.extensions.uninstall.usage.description = Remove previously installed Fortify extensions from AI coding assistant directories and clean up fcli state. -fcli.ai-assist.extensions.uninstall.output.table.args = assistant,contentType,targetDir,fileCount,__action__ -fcli.ai-assist.extensions.update.usage.header = Update installed Fortify extensions -fcli.ai-assist.extensions.update.usage.description = Update installed extensions: add new files, update changed files, and remove files no longer in the source. Can also be used for first-time install. -fcli.ai-assist.extensions.update.output.table.args = assistant,contentType,targetDir,fileCount,__action__ +fcli.ai-assist.extensions.usage.description = Set up, uninstall, or check status of Fortify extensions (skills, agents, plugins) for AI coding assistants like Claude Code, GitHub Copilot, OpenAI Codex, and Gemini CLI. +fcli.ai-assist.extensions.setup.usage.header = Set up Fortify extensions for coding assistants +fcli.ai-assist.extensions.setup.usage.description = Set up Fortify extensions (skills, agents, plugins) for AI coding \ + assistants. This command is idempotent: it installs extensions if not present, or updates them if already installed \ + (adding new files, updating changed files, removing obsolete files).%n\ + %nExactly one of --assistants, --auto-detect, or --dir must be specified:%n\ + - --assistants: explicitly name the coding assistants to target%n\ + - --auto-detect: let fcli discover installed assistants via heuristic checks%n\ + - --dir: install content directly to a specific directory (requires --content-types)%n\ + %nTARGET DIRECTORY DEDUPLICATION: When multiple assistants share a target directory for the same content type, \ + extensions are installed only once to that directory. This avoids duplicate entries that some AI assistants would \ + see if extensions were present in multiple locations. For example, if extensions are set up for both VS Code \ + (GitHub Copilot) and Claude Code, skills may be installed to ~/.claude/skills only, since VS Code also processes \ + that directory by default. The setup output marks such shared directories as EXISTING for the second assistant.%n\ + %nNOTE: Auto-detection uses heuristic checks (directory and command existence) which may produce false positives \ + from stale directories of previously uninstalled tools, or miss IDE-embedded assistants (e.g., Copilot in Eclipse). \ + For reliable results, use --assistants to explicitly specify your active assistants. Run \ + 'fcli ai-assist extensions list-assistants --detect' to preview detection results before using --auto-detect. +fcli.ai-assist.extensions.setup.output.table.args = assistant,contentType,targetDir,fileCount,__action__ +fcli.ai-assist.extensions.uninstall.usage.header = Remove installed Fortify extensions +fcli.ai-assist.extensions.uninstall.usage.description = Remove previously installed Fortify extensions from all known \ + AI coding assistant directories and clean up fcli state. Scans both the distribution descriptor and fcli state to \ + find all target directories. Use --content-types to remove only specific content types. +fcli.ai-assist.extensions.uninstall.output.table.args = contentType,targetDir,fileCount,__action__ fcli.ai-assist.extensions.list-versions.usage.header = List available extension versions fcli.ai-assist.extensions.list-versions.usage.description = List all available versions of Fortify AI assistant extensions from the tool definitions, including aliases and stability status. fcli.ai-assist.extensions.list-versions.output.table.args = version,aliases,stable @@ -24,11 +38,11 @@ fcli.ai-assist.extensions.list-assistants.usage.description = List all AI coding fcli.ai-assist.extensions.list-assistants.output.table.args = id,name,contentTypesString,detected,installed # Shared option descriptions for extensions commands -fcli.ai-assist.extensions.assistants = Restrict to specific assistants (e.g., --assistants claude,copilot). Default: all detected. -fcli.ai-assist.extensions.exclude-assistants = Exclude specific assistants from the operation. +fcli.ai-assist.extensions.assistants = Specify which assistants to target (e.g., --assistants claude,copilot). Mutually exclusive with --auto-detect and --dir. +fcli.ai-assist.extensions.auto-detect = Auto-detect installed assistants using heuristic checks. Mutually exclusive with --assistants and --dir. fcli.ai-assist.extensions.version = Extension version to install or update to (e.g., 1.0.0, latest, stable). Default: latest. fcli.ai-assist.extensions.source = Extensions source: local zip file or local directory. Overrides version resolution from tool definitions. -fcli.ai-assist.extensions.dir = Install to a specific directory instead of auto-detected locations. Requires --content-types. Bypasses assistant detection. +fcli.ai-assist.extensions.dir = Install content to a specific directory, bypassing assistant selection. Requires --content-types. Mutually exclusive with --assistants and --auto-detect. fcli.ai-assist.extensions.content-types = Filter by content type: skills, agents, plugins. Default: all. Required with --dir. fcli.ai-assist.extensions.on-digest-mismatch = Action when downloaded zip digest does not match tool definitions: warn, fail. Default: fail. fcli.ai-assist.extensions.confirm = Skip confirmation prompt. diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy index 93bf29e9045..cb6c8637a42 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy @@ -1,21 +1,139 @@ package com.fortify.cli.ftest.core +import java.nio.file.Files +import java.nio.file.Path + import com.fortify.cli.ftest._common.Fcli import com.fortify.cli.ftest._common.spec.FcliBaseSpec import com.fortify.cli.ftest._common.spec.Prefix +import com.fortify.cli.ftest._common.spec.TempDir +import spock.lang.Shared import spock.lang.Stepwise @Prefix("core.ai-assist.extensions") @Stepwise class AiAssistExtensionsSpec extends FcliBaseSpec { + @Shared @TempDir("ai-assist/extensions") String baseDir; + + def "list-versions"() { + when: + def result = Fcli.run("ai-assist extensions list-versions") + then: + verifyAll(result.stdout) { + size() > 1 + it[0].replace(' ', '').equals("VersionAliasesStable") + } + } + + def "list-assistants"() { + when: + def result = Fcli.run("ai-assist extensions list-assistants") + then: + verifyAll(result.stdout) { + size() > 1 + it[0].replace(' ', '').equals("IdNameContenttypesDetectedInstalled") + } + } + + def "list-assistants-detect"() { + when: + def result = Fcli.run("ai-assist extensions list-assistants --detect") + then: + verifyAll(result.stdout) { + size() > 1 + it[0].replace(' ', '').equals("IdNameContenttypesDetectedInstalled") + // With --detect, Detected column should show true/false, not N/A + !it[1].contains("N/A") + } + } + + def "setup-no-target"() { + when: + def result = Fcli.run("ai-assist extensions setup --dry-run -y", + {it.expectSuccess(false)}) + then: + verifyAll(result.stderr) { + it.any { it.contains("--assistants") || it.contains("--auto-detect") || it.contains("--dir") } + } + } - def "list-installed"() { + def "setup-unknown-assistant"() { + when: + def result = Fcli.run("ai-assist extensions setup --assistants unknown-assistant --dry-run -y", + {it.expectSuccess(false)}) + then: + verifyAll(result.stderr) { + it.any { it.contains("Unknown assistant: unknown-assistant") } + } + } + + def "setup-dry-run"() { + def targetDir = "${baseDir}/skills-dry-run" + when: + def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills --dry-run -y", + {it.expectZeroExitCode()}) + then: + verifyAll(result.stdout) { + size() > 0 + it[0].replace(' ', '').equals("AssistantContenttypeTargetdirFilecountAction") + it[1].contains("INSTALLED") + } + // Dry run should not create files + !Files.exists(Path.of(targetDir)) + } + + def "setup-install"() { + def targetDir = "${baseDir}/skills" + when: + def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills -y", + {it.expectZeroExitCode()}) + then: + verifyAll(result.stdout) { + size() > 0 + it[0].replace(' ', '').equals("AssistantContenttypeTargetdirFilecountAction") + it[1].contains("INSTALLED") + } + // Files should exist now + Files.exists(Path.of(targetDir)) + Files.exists(Path.of(targetDir, ".fortify-extensions.json")) + } + + def "list-installed-after-setup"() { when: def result = Fcli.run("ai-assist extensions list-installed") + then: + verifyAll(result.stdout) { + // --dir mode doesn't record installations state, so still "No data" + size() == 1 + it[0].trim() == "No data" + } + } + + def "setup-idempotent"() { + def targetDir = "${baseDir}/skills" + when: + def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills -y", + {it.expectZeroExitCode()}) + then: + verifyAll(result.stdout) { + size() > 0 + it[0].replace(' ', '').equals("AssistantContenttypeTargetdirFilecountAction") + // Re-running setup on same dir should report UNCHANGED + it[1].contains("UNCHANGED") + } + } + + def "uninstall-no-state"() { + // Uninstall finds nothing because --dir mode doesn't register installations in fcli state + when: + def result = Fcli.run("ai-assist extensions uninstall --content-types skills -y", + {it.expectZeroExitCode()}) then: verifyAll(result.stdout) { size() == 1 it[0].trim() == "No data" } + // Manifest should still exist since uninstall didn't know about this dir + Files.exists(Path.of("${baseDir}/skills", ".fortify-extensions.json")) } } From d814551f3a52b5c14502e701a5f2d7f2944ef1ec Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 22 May 2026 13:43:35 +0200 Subject: [PATCH 41/55] chore: Use content-specific descriptors --- .../helper/AiAssistExtensionsInstaller.java | 101 +++++++++++------- .../AiAssistExtensionsTargetDirManifest.java | 27 ++++- .../ftest/core/AiAssistExtensionsSpec.groovy | 4 +- 3 files changed, 85 insertions(+), 47 deletions(-) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java index e8b099541bf..f8ae6781a7b 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java @@ -49,7 +49,7 @@ * lightweight registry of which assistants were set up and their resolved * target directories. Used by {@code list-installed} and {@code uninstall} * to work without the distribution descriptor. - *
  • Target-dir manifest ({@code .fortify-extensions.json} in each target dir): + *
  • Target-dir manifest ({@code .fortify-extensions..json} in each target dir): * records content type, version, and file list. Enables diff-based updates * and state recovery after fcli state reset.
  • * @@ -140,28 +140,28 @@ public static List uninstall( var results = new ArrayList(); for (var dir : targetDirs) { - var manifest = readTargetDirManifest(dir); - if (manifest == null) { continue; } - if (!matchesContentTypeFilter(manifest.getContentType(), contentTypeFilter)) { - continue; - } + for (var manifest : readAllTargetDirManifests(dir)) { + if (!matchesContentTypeFilter(manifest.getContentType(), contentTypeFilter)) { + continue; + } - var files = manifest.getFiles() != null ? manifest.getFiles() : List.of(); - if (!dryRun) { - for (var file : files) { - deleteTargetFile(dir.resolve(file)); + var files = manifest.getFiles() != null ? manifest.getFiles() : List.of(); + if (!dryRun) { + for (var file : files) { + deleteTargetFile(dir.resolve(file)); + } + deleteManifestFile(dir, manifest.getContentType()); } - deleteManifestFile(dir); + results.add(AiAssistExtensionsOutputDescriptor.builder() + .contentType(manifest.getContentType()) + .targetDir(dir.toString()) + .fileCount(files.size()) + .sourceVersion(manifest.getVersion()) + .files(files.toArray(String[]::new)) + .filesString(String.join(", ", files)) + .actionResult("REMOVED") + .build()); } - results.add(AiAssistExtensionsOutputDescriptor.builder() - .contentType(manifest.getContentType()) - .targetDir(dir.toString()) - .fileCount(files.size()) - .sourceVersion(manifest.getVersion()) - .files(files.toArray(String[]::new)) - .filesString(String.join(", ", files)) - .actionResult("REMOVED") - .build()); } if (!dryRun) { @@ -182,7 +182,7 @@ public static List listInstalled() { for (var targetEntry : installation.getTargets().entrySet()) { var contentType = targetEntry.getKey(); var targetDir = Path.of(targetEntry.getValue()); - var manifest = readTargetDirManifest(targetDir); + var manifest = readTargetDirManifest(targetDir, contentType); var files = manifest != null && manifest.getFiles() != null ? manifest.getFiles() : List.of(); var version = manifest != null ? manifest.getVersion() : null; @@ -244,8 +244,8 @@ public static List listAssistants(b String installedVersion = null; if (installed) { // Read version from first target dir manifest - installedVersion = assistantInstallation.getTargets().values().stream() - .map(dir -> readTargetDirManifest(Path.of(dir))) + installedVersion = assistantInstallation.getTargets().entrySet().stream() + .map(e -> readTargetDirManifest(Path.of(e.getValue()), e.getKey())) .filter(m -> m != null) .map(AiAssistExtensionsTargetDirManifest::getVersion) .findFirst().orElse(null); @@ -355,7 +355,7 @@ private static List buildSetupPlan( } // Read existing manifest from target dir for diff - var existingManifest = readTargetDirManifest(resolvedDir); + var existingManifest = readTargetDirManifest(resolvedDir, contentType); var existingFiles = existingManifest != null && existingManifest.getFiles() != null ? new HashSet<>(existingManifest.getFiles()) : Set.of(); @@ -434,7 +434,7 @@ private static List buildCustomDirPlan( if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } var ctDesc = ctEntry.getValue(); - var existingManifest = readTargetDirManifest(resolvedDir); + var existingManifest = readTargetDirManifest(resolvedDir, contentType); var existingFiles = existingManifest != null && existingManifest.getFiles() != null ? new HashSet<>(existingManifest.getFiles()) : Set.of(); @@ -699,13 +699,14 @@ private static boolean matchesGlob(String filename, String glob) { private static void executeSetupPlan(List plan, AiAssistExtensionsSourceHandler sourceHandler) { - // Group by target dir to write one manifest per dir - var byTargetDir = plan.stream() + // Group by (target dir, content type) to write one manifest per combo + var byDirAndType = plan.stream() .filter(e -> !"EXISTING".equals(e.action())) - .collect(Collectors.groupingBy(PlanEntry::targetDir, + .collect(Collectors.groupingBy( + e -> e.targetDir() + "\0" + e.contentType(), LinkedHashMap::new, Collectors.toList())); - for (var dirEntries : byTargetDir.values()) { + for (var dirEntries : byDirAndType.values()) { for (var entry : dirEntries) { switch (entry.action()) { case "INSTALLED", "UPDATED" -> installFile(sourceHandler, entry); @@ -713,7 +714,7 @@ private static void executeSetupPlan(List plan, } } - // Write manifest for this target dir + // Write manifest for this (target dir, content type) pair var first = dirEntries.get(0); var installedFiles = dirEntries.stream() .filter(e -> !"REMOVED".equals(e.action())) @@ -749,7 +750,8 @@ private static void writeTargetDirManifest(Path targetDir, String contentType, .timestamp(Instant.now().toString()) .files(files) .build(); - var manifestPath = targetDir.resolve(AiAssistExtensionsTargetDirManifest.MANIFEST_FILENAME); + var manifestPath = targetDir.resolve( + AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); try { Files.createDirectories(targetDir); var json = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() @@ -760,9 +762,30 @@ private static void writeTargetDirManifest(Path targetDir, String contentType, } } - static AiAssistExtensionsTargetDirManifest readTargetDirManifest(Path targetDir) { - var manifestPath = targetDir.resolve(AiAssistExtensionsTargetDirManifest.MANIFEST_FILENAME); + static AiAssistExtensionsTargetDirManifest readTargetDirManifest( + Path targetDir, String contentType) { + var manifestPath = targetDir.resolve( + AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); if (!Files.isRegularFile(manifestPath)) { return null; } + return readManifestFile(manifestPath); + } + + static List readAllTargetDirManifests(Path targetDir) { + if (!Files.isDirectory(targetDir)) { return List.of(); } + var glob = AiAssistExtensionsTargetDirManifest.manifestGlob(); + var result = new ArrayList(); + try (var stream = Files.newDirectoryStream(targetDir, glob)) { + for (var path : stream) { + var manifest = readManifestFile(path); + if (manifest != null) { result.add(manifest); } + } + } catch (IOException e) { + LOG.warn("Error listing manifests in: {}", targetDir, e); + } + return result; + } + + private static AiAssistExtensionsTargetDirManifest readManifestFile(Path manifestPath) { try { var content = Files.readString(manifestPath); return JsonHelper.getObjectMapper() @@ -773,8 +796,9 @@ static AiAssistExtensionsTargetDirManifest readTargetDirManifest(Path targetDir) } } - private static void deleteManifestFile(Path targetDir) { - var manifestPath = targetDir.resolve(AiAssistExtensionsTargetDirManifest.MANIFEST_FILENAME); + private static void deleteManifestFile(Path targetDir, String contentType) { + var manifestPath = targetDir.resolve( + AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); try { Files.deleteIfExists(manifestPath); } catch (IOException e) { @@ -907,12 +931,9 @@ private static void warnDuplicateContentDirs( var resolvedDirs = AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); var dirsWithManifest = resolvedDirs.stream() - .filter(dir -> readTargetDirManifest(dir) != null + .filter(dir -> readTargetDirManifest(dir, contentType) != null || selectedAssistants.containsKey(assistantId)) - .filter(dir -> { - var m = readTargetDirManifest(dir); - return m != null && contentType.equals(m.getContentType()); - }) + .filter(dir -> readTargetDirManifest(dir, contentType) != null) .toList(); if (dirsWithManifest.size() > 1) { diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.java index 1885334ca40..5eeb9ceeca7 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.java @@ -24,15 +24,32 @@ import lombok.NoArgsConstructor; /** - * Manifest stored as {@code .fortify-extensions.json} in each target directory. - * Tracks what was installed in that directory (files, version, content type) - * without recording which assistants use this directory. This allows - * recovery of installation state even if the fcli state is reset. + * Manifest stored as {@code .fortify-extensions..json} in each + * target directory. Tracks what was installed in that directory (files, version, + * content type) without recording which assistants use this directory. This + * allows recovery of installation state even if the fcli state is reset. + *

    + * Each content type gets its own manifest file, so multiple content types can + * coexist in the same target directory without overwriting each other. */ @JsonIgnoreProperties(ignoreUnknown=true) @Reflectable @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) @Data public class AiAssistExtensionsTargetDirManifest { - public static final String MANIFEST_FILENAME = ".fortify-extensions.json"; + private static final String MANIFEST_PREFIX = ".fortify-extensions."; + private static final String MANIFEST_SUFFIX = ".json"; + private static final String MANIFEST_GLOB = MANIFEST_PREFIX + "*" + MANIFEST_SUFFIX; + + public static String manifestFilename(String contentType) { + return MANIFEST_PREFIX + sanitize(contentType) + MANIFEST_SUFFIX; + } + + public static String manifestGlob() { + return MANIFEST_GLOB; + } + + static String sanitize(String contentType) { + return contentType.replaceAll("[^a-zA-Z0-9_-]", "_"); + } @JsonProperty("schema-version") private int schemaVersion; diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy index cb6c8637a42..5caa4e43df8 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy @@ -95,7 +95,7 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { } // Files should exist now Files.exists(Path.of(targetDir)) - Files.exists(Path.of(targetDir, ".fortify-extensions.json")) + Files.exists(Path.of(targetDir, ".fortify-extensions.skills.json")) } def "list-installed-after-setup"() { @@ -134,6 +134,6 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { it[0].trim() == "No data" } // Manifest should still exist since uninstall didn't know about this dir - Files.exists(Path.of("${baseDir}/skills", ".fortify-extensions.json")) + Files.exists(Path.of("${baseDir}/skills", ".fortify-extensions.skills.json")) } } From 9ce334c1e3ac858f47119284351c76041a505ffa Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 22 May 2026 13:52:20 +0200 Subject: [PATCH 42/55] chore: Add uninstall --dir option --- .../cmd/AiAssistExtensionsUninstallCommand.java | 6 +++++- .../helper/AiAssistExtensionsInstaller.java | 14 +++++++++----- .../ai_assist/i18n/AiAssistMessages.properties | 9 ++++++--- .../cli/ftest/core/AiAssistExtensionsSpec.groovy | 15 ++++++++------- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java index d083141f39b..b08fd9967e1 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java @@ -36,6 +36,10 @@ public class AiAssistExtensionsUninstallCommand extends AbstractOutputCommand descriptionKey = "fcli.ai-assist.extensions.content-types") private Set contentTypeFilter; + @Option(names = {"--dir"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.uninstall.dir") + private String customDir; + @Option(names = {"-y", "--confirm"}, descriptionKey = "fcli.ai-assist.extensions.confirm") private boolean confirm; @@ -47,7 +51,7 @@ public class AiAssistExtensionsUninstallCommand extends AbstractOutputCommand @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.uninstall(contentTypeFilter, dryRun)); + AiAssistExtensionsInstaller.uninstall(contentTypeFilter, customDir, dryRun)); } @Override diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java index f8ae6781a7b..85eee849743 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java @@ -128,15 +128,19 @@ public static List setup( // ──────────────────────────── Uninstall ──────────────────────────── /** - * Uninstall extensions from all known target directories. Scans the union - * of dirs from the distribution descriptor and fcli state to find manifests. + * Uninstall extensions from target directories. When {@code customDir} is + * specified, only that directory is scanned. Otherwise, scans the union of + * dirs from the distribution descriptor and fcli state. * * @param contentTypeFilter optional content type filter, or null for all + * @param customDir specific directory to uninstall from, or null for all known dirs * @param dryRun if true, report only without deleting */ public static List uninstall( - Set contentTypeFilter, boolean dryRun) { - var targetDirs = collectAllKnownTargetDirs(); + Set contentTypeFilter, String customDir, boolean dryRun) { + var targetDirs = customDir != null + ? Set.of(Path.of(customDir).toAbsolutePath().normalize()) + : collectAllKnownTargetDirs(); var results = new ArrayList(); for (var dir : targetDirs) { @@ -164,7 +168,7 @@ public static List uninstall( } } - if (!dryRun) { + if (!dryRun && customDir == null) { clearInstallationsState(contentTypeFilter); } return results; diff --git a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties index 4412750b087..e8938fe2ac5 100644 --- a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties @@ -23,9 +23,12 @@ fcli.ai-assist.extensions.setup.usage.description = Set up Fortify extensions (s 'fcli ai-assist extensions list-assistants --detect' to preview detection results before using --auto-detect. fcli.ai-assist.extensions.setup.output.table.args = assistant,contentType,targetDir,fileCount,__action__ fcli.ai-assist.extensions.uninstall.usage.header = Remove installed Fortify extensions -fcli.ai-assist.extensions.uninstall.usage.description = Remove previously installed Fortify extensions from all known \ - AI coding assistant directories and clean up fcli state. Scans both the distribution descriptor and fcli state to \ - find all target directories. Use --content-types to remove only specific content types. +fcli.ai-assist.extensions.uninstall.usage.description = Remove previously installed Fortify extensions and clean up \ + fcli state. By default, scans both the distribution descriptor and fcli state to find all target directories. \ + Use --dir to remove extensions from a specific directory instead. Use --content-types to remove only specific \ + content types. +fcli.ai-assist.extensions.uninstall.dir = Remove extensions from a specific directory only, without affecting \ + fcli state or other directories. fcli.ai-assist.extensions.uninstall.output.table.args = contentType,targetDir,fileCount,__action__ fcli.ai-assist.extensions.list-versions.usage.header = List available extension versions fcli.ai-assist.extensions.list-versions.usage.description = List all available versions of Fortify AI assistant extensions from the tool definitions, including aliases and stability status. diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy index 5caa4e43df8..e0fbb7a6531 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy @@ -123,17 +123,18 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { } } - def "uninstall-no-state"() { - // Uninstall finds nothing because --dir mode doesn't register installations in fcli state + def "uninstall-dir"() { + def targetDir = "${baseDir}/skills" when: - def result = Fcli.run("ai-assist extensions uninstall --content-types skills -y", + def result = Fcli.run("ai-assist extensions uninstall --dir ${targetDir} --content-types skills -y", {it.expectZeroExitCode()}) then: verifyAll(result.stdout) { - size() == 1 - it[0].trim() == "No data" + size() > 0 + it[0].replace(' ', '').equals("ContenttypeTargetdirFilecountAction") + it[1].contains("REMOVED") } - // Manifest should still exist since uninstall didn't know about this dir - Files.exists(Path.of("${baseDir}/skills", ".fortify-extensions.skills.json")) + // Manifest should be gone + !Files.exists(Path.of(targetDir, ".fortify-extensions.skills.json")) } } From 408dd305134d53b06e2ee2c15f0d6873d178bad8 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 22 May 2026 14:52:04 +0200 Subject: [PATCH 43/55] chore: Rename methods to indicate resource management requirement --- .../cli/common/action/helper/ActionLoaderHelper.java | 6 +++--- .../main/java/com/fortify/cli/common/util/FileUtils.java | 8 ++++---- .../tool/definitions/helper/ToolDefinitionsHelper.java | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/helper/ActionLoaderHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/helper/ActionLoaderHelper.java index 8fc0120de01..fa91149f850 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/helper/ActionLoaderHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/helper/ActionLoaderHelper.java @@ -404,15 +404,15 @@ private static final ActionSource common(String type) { @SneakyThrows private static final Supplier customActionsInputStreamSupplier(String type) { - return ()->FileUtils.getInputStream(customActionsZipPath(type)); + return ()->FileUtils.openInputStream(customActionsZipPath(type)); } private static final Supplier builtinActionsInputStreamSupplier(String type) { - return ()->FileUtils.getResourceInputStream(builtinActionsResourceZip(type)); + return ()->FileUtils.openResourceInputStream(builtinActionsResourceZip(type)); } private static final Supplier commonActionsInputStreamSupplier() { - return ()->FileUtils.getResourceInputStream(commonActionsResourceZip()); + return ()->FileUtils.openResourceInputStream(commonActionsResourceZip()); } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java index beb6c34cfe0..fe3dda16276 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java @@ -55,11 +55,11 @@ public final class FileUtils { private FileUtils() {} @SneakyThrows - public static final InputStream getInputStream(Path path) { + public static final InputStream openInputStream(Path path) { return !Files.exists(path) ? null : Files.newInputStream(path); } - public static final InputStream getResourceInputStream(String resourcePath) { + public static final InputStream openResourceInputStream(String resourcePath) { return Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath); } @@ -87,7 +87,7 @@ public static final byte[] checkCharset(byte[] bytes, Charset charset) { @SneakyThrows public static final byte[] readResourceAsBytes(String resourcePath) { - try ( InputStream in = getResourceInputStream(resourcePath) ) { + try ( InputStream in = openResourceInputStream(resourcePath) ) { return in.readAllBytes(); } } @@ -119,7 +119,7 @@ public static final void copyResource(String resourcePath, Path destinationFileP } catch (IOException e) { throw new FcliSimpleException(String.format("Error creating directory %s", parent), e); } - try ( InputStream in = getResourceInputStream(resourcePath) ) { + try ( InputStream in = openResourceInputStream(resourcePath) ) { Files.copy( in, destinationFilePath, options); } catch ( IOException e ) { throw new FcliSimpleException(String.format("Error copying resource %s to %s", resourcePath, destinationFilePath), e); diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java index eb115b1c9e5..87b9a20df1c 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java @@ -331,7 +331,7 @@ private static boolean copyEntryFromZipToZip(Path zipPath, String fileName, ZipO private static boolean copyEntryFromResourceZipToZip(String resourceZip, String fileName, ZipOutputStream zos) throws IOException { - try (InputStream is = FileUtils.getResourceInputStream(resourceZip); + try (InputStream is = FileUtils.openResourceInputStream(resourceZip); ZipInputStream zis = new ZipInputStream(is)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { @@ -475,7 +475,7 @@ public void close() { private static Path ensureStateZipExists() { if (!Files.exists(DEFINITIONS_STATE_ZIP)) { createDefinitionsStateDir(DEFINITIONS_STATE_DIR); - try (InputStream is = FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP)) { + try (InputStream is = FileUtils.openResourceInputStream(DEFINITIONS_INTERNAL_ZIP)) { Files.copy(is, DEFINITIONS_STATE_ZIP); } // Set epoch timestamp so the age check treats this as stale and triggers @@ -516,7 +516,7 @@ private static String determineActionResult(ToolDefinitionsStateDescriptor state private static Set getRequiredFileNames() { Set names = new HashSet<>(); - try (InputStream is = FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP); + try (InputStream is = FileUtils.openResourceInputStream(DEFINITIONS_INTERNAL_ZIP); ZipInputStream zis = new ZipInputStream(is)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { @@ -645,7 +645,7 @@ private static void addYamlDescriptor(List resu } private static Date getInternalResourceZipEntryLastModified(String fileName) { - try (InputStream is = FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP); + try (InputStream is = FileUtils.openResourceInputStream(DEFINITIONS_INTERNAL_ZIP); ZipInputStream zis = new ZipInputStream(is)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { From b1967d4525f6eefdba0c173b0c9f6b0ed873722f Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sat, 23 May 2026 21:00:16 +0200 Subject: [PATCH 44/55] fix: `fcli tool definitions update`: Ignore spurious intermediate directories in tool-definitions.zip --- .../tool/definitions/helper/ToolDefinitionsHelper.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java index 87b9a20df1c..ffbdd3f79c5 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java @@ -139,7 +139,14 @@ private static List getOutputDescriptors(String private static final ToolDefinitionsStateDescriptor update(String source, Path dest) throws IOException { try { - UnirestHelper.download("tool", new URL(source).toString(), dest.toFile()); + var url = new URL(source); + Path tempFile = Files.createTempFile("tool-definitions-", ".zip"); + try { + UnirestHelper.download("tool", url.toString(), tempFile.toFile()); + mergeDefinitionsZip(dest, tempFile.toString()); + } finally { + Files.deleteIfExists(tempFile); + } } catch (MalformedURLException e) { if (!isValidZip(source)) { throw new FcliSimpleException("Invalid tool definitions file", e); From e8cd4c84534c0d944b006780020a019ed684d9f9 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sat, 23 May 2026 21:32:46 +0200 Subject: [PATCH 45/55] chore: Updates based on review --- .../helper/AiAssistExtensionsInstaller.java | 26 +++++++++---- .../AiAssistExtensionsSourceHandler.java | 38 ++++++++++++------- .../mcp/helper/http/MCPServerHttpConfig.java | 2 +- ...CPServerHttpSessionDescriptorResolver.java | 27 +++++++------ .../mcp/config/mcp-http-config-fod.yaml | 2 +- .../mcp/config/mcp-http-config-ssc.yaml | 2 +- .../common/cli/util/FcliExecutionContext.java | 1 + 7 files changed, 62 insertions(+), 36 deletions(-) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java index 85eee849743..a7fdcbcf68d 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java @@ -13,6 +13,7 @@ package com.fortify.cli.ai_assist.extensions.helper; import java.io.IOException; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; @@ -152,7 +153,7 @@ public static List uninstall( var files = manifest.getFiles() != null ? manifest.getFiles() : List.of(); if (!dryRun) { for (var file : files) { - deleteTargetFile(dir.resolve(file)); + deleteTargetFile(safeResolve(dir, file)); } deleteManifestFile(dir, manifest.getContentType()); } @@ -367,7 +368,7 @@ private static List buildSetupPlan( var handledRelPaths = new HashSet(); for (var sourceFile : sourceFiles) { var targetRelPath = getTargetRelativePath(contentManifest, target, sourceFile); - var targetAbsPath = resolvedDir.resolve(targetRelPath).toString(); + var targetAbsPath = safeResolve(resolvedDir, targetRelPath).toString(); handledRelPaths.add(targetRelPath); String action; @@ -390,7 +391,7 @@ private static List buildSetupPlan( plan.add(new PlanEntry( assistant.getDisplayName(), assistantId, contentType, resolvedDir.toString(), null, existingFile, - resolvedDir.resolve(existingFile).toString(), + safeResolve(resolvedDir, existingFile).toString(), sourceVersion, "REMOVED")); } } @@ -412,7 +413,7 @@ private static void addExistingEntries( plan.add(new PlanEntry( assistant.getDisplayName(), assistantId, contentType, resolvedDir.toString(), sourceFile, targetRelPath, - resolvedDir.resolve(targetRelPath).toString(), + safeResolve(resolvedDir, targetRelPath).toString(), sourceVersion, "EXISTING")); } } @@ -446,7 +447,7 @@ private static List buildCustomDirPlan( var handledRelPaths = new HashSet(); for (var sourceFile : sourceFiles) { var targetRelPath = getTargetRelativePathForContentType(ctDesc, sourceFile); - var targetAbsPath = resolvedDir.resolve(targetRelPath).toString(); + var targetAbsPath = safeResolve(resolvedDir, targetRelPath).toString(); handledRelPaths.add(targetRelPath); String action; @@ -468,7 +469,7 @@ private static List buildCustomDirPlan( plan.add(new PlanEntry( null, null, contentType, resolvedDir.toString(), null, existingFile, - resolvedDir.resolve(existingFile).toString(), + safeResolve(resolvedDir, existingFile).toString(), sourceVersion, "REMOVED")); } } @@ -695,8 +696,8 @@ private static String getTargetRelativePathForContentType( } private static boolean matchesGlob(String filename, String glob) { - var regex = glob.replace(".", "\\.").replace("*", ".*"); - return filename.matches(regex); + var matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob); + return matcher.matches(Path.of(filename)); } // ──────────────────────────── Plan execution ──────────────────────────── @@ -988,4 +989,13 @@ private static boolean hasFileChanged( private static boolean matchesContentTypeFilter(String contentType, Set filter) { return filter == null || filter.isEmpty() || filter.contains(contentType); } + + private static Path safeResolve(Path baseDir, String relativePath) { + var resolved = baseDir.resolve(relativePath).normalize(); + if (!resolved.startsWith(baseDir.normalize())) { + throw new FcliSimpleException( + "Path traversal detected: " + relativePath); + } + return resolved; + } } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java index d1d67fc16bb..44770505651 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java @@ -17,7 +17,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; -import java.util.stream.Stream; +import java.util.List; import java.util.zip.ZipFile; import org.slf4j.Logger; @@ -210,7 +210,7 @@ public static AiAssistExtensionsDistributionDescriptor readDistributionDescripto } public byte[] readFileBytes(String relativePath) { - var filePath = extractedDir.resolve(relativePath); + var filePath = safeResolve(relativePath); if (!Files.isRegularFile(filePath)) { return null; } try { return Files.readAllBytes(filePath); @@ -220,31 +220,41 @@ public byte[] readFileBytes(String relativePath) { } public boolean exists(String relativePath) { - return Files.exists(extractedDir.resolve(relativePath)); + return Files.exists(safeResolve(relativePath)); } - public Stream listFiles(String relativePath) { - var dir = extractedDir.resolve(relativePath); - if (!Files.isDirectory(dir)) { return Stream.empty(); } - try { - return Files.walk(dir) + public List listFiles(String relativePath) { + var dir = safeResolve(relativePath); + if (!Files.isDirectory(dir)) { return List.of(); } + try (var stream = Files.walk(dir)) { + return stream .filter(Files::isRegularFile) - .map(p -> extractedDir.relativize(p)); + .map(p -> extractedDir.relativize(p)) + .toList(); } catch (IOException e) { throw new FcliTechnicalException("Error listing files in: " + relativePath, e); } } - public Stream listDirs(String relativePath) { - var dir = extractedDir.resolve(relativePath); - if (!Files.isDirectory(dir)) { return Stream.empty(); } - try { - return Files.list(dir).filter(Files::isDirectory); + public List listDirs(String relativePath) { + var dir = safeResolve(relativePath); + if (!Files.isDirectory(dir)) { return List.of(); } + try (var stream = Files.list(dir)) { + return stream.filter(Files::isDirectory).toList(); } catch (IOException e) { throw new FcliTechnicalException("Error listing dirs in: " + relativePath, e); } } + private Path safeResolve(String relativePath) { + var resolved = extractedDir.resolve(relativePath).normalize(); + if (!resolved.startsWith(extractedDir.normalize())) { + throw new FcliSimpleException( + "Path traversal detected: " + relativePath); + } + return resolved; + } + @Override public void close() { if (tempDir) { diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfig.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfig.java index ac83610180e..99e524833c5 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfig.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfig.java @@ -66,7 +66,7 @@ public enum Product { public static class ServerConfig { private int port = 8080; private String bindAddress; - private long maxRequestBodyBytes = -1; + private long maxRequestBodyBytes = 10 * 1024 * 1024; private TlsConfig tls; @JsonIgnore diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 344004c1ba9..0c31ad82f4b 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -17,7 +17,7 @@ import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.HashSet; +import java.util.Arrays; import java.util.HexFormat; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -343,15 +343,20 @@ private FoDTokenCreateResponse createFoDTokenResponse(ParsedAuthorization auth, DEFAULT_FOD_SCOPES ); } - return FoDOAuthHelper.createToken( - urlConfig, - new HttpMcpFoDUserCredentials( - auth.fodTenant(), - auth.fodUser(), - auth.fodPat().toCharArray() - ), - DEFAULT_FOD_SCOPES - ); + var pwd = auth.fodPat().toCharArray(); + try { + return FoDOAuthHelper.createToken( + urlConfig, + new HttpMcpFoDUserCredentials( + auth.fodTenant(), + auth.fodUser(), + pwd + ), + DEFAULT_FOD_SCOPES + ); + } finally { + Arrays.fill(pwd, '\0'); + } } private static final class HttpMcpFoDClientCredentials implements IFoDClientCredentials { @@ -420,7 +425,7 @@ public String getScSastControllerUrl() { @Override public Set getDisabledComponents() { - return new HashSet<>(); + return Set.of(); } @Override diff --git a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-fod.yaml b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-fod.yaml index 223990ba698..9d47df0e570 100644 --- a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-fod.yaml +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-fod.yaml @@ -1,7 +1,7 @@ server: port: 8080 # bindAddress: "" # Network interface to bind; empty = all interfaces (0.0.0.0) - # maxRequestBodyBytes: -1 # Maximum request body in bytes; -1 = unlimited + # maxRequestBodyBytes: 10485760 # Maximum request body in bytes; default 10MB # tls: # Omit entire section to use plain HTTP # keystoreFile: keystore.p12 # Path to Java keystore (relative to config or absolute) # keystorePassword: ${#env('KEYSTORE_PASSWORD')} # Keystore password diff --git a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-ssc.yaml b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-ssc.yaml index bffd03c7bf7..ea2770c1958 100644 --- a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-ssc.yaml +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-ssc.yaml @@ -1,7 +1,7 @@ server: port: 8080 # bindAddress: "" # Network interface to bind; empty = all interfaces (0.0.0.0) - # maxRequestBodyBytes: -1 # Maximum request body in bytes; -1 = unlimited + # maxRequestBodyBytes: 10485760 # Maximum request body in bytes; default 10MB # tls: # Omit entire section to use plain HTTP # keystoreFile: keystore.p12 # Path to Java keystore (relative to config or absolute) # keystorePassword: ${#env('KEYSTORE_PASSWORD')} # Keystore password diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java index 4625b5ee1f4..3c5fc6ba4cb 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java @@ -132,6 +132,7 @@ public String info() { public boolean enableEphemeralEncryption() { if ( encryptionHelper!=EncryptionHelper.DEFAULT ) { return true; } synchronized(this) { + if ( encryptionHelper!=EncryptionHelper.DEFAULT ) { return true; } var rnd = new byte[32]; new SecureRandom().nextBytes(rnd); String pwd = Base64.getUrlEncoder().withoutPadding().encodeToString(rnd); From ba743ce4c89e2092b176b5f4015344a92c7cecce Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 11:54:54 +0200 Subject: [PATCH 46/55] chore: Fixes & improvements --- .../cmd/AiAssistExtensionsSetupCommand.java | 4 -- .../AiAssistExtensionsUninstallCommand.java | 4 -- .../AiAssistMCPCreateHttpConfigCommand.java | 4 +- .../i18n/AiAssistMessages.properties | 1 - .../ftest/core/AiAssistExtensionsSpec.groovy | 12 ++-- .../cli/ftest/core/AiAssistMcpSpec.groovy | 64 +++++++++++++++++++ 6 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistMcpSpec.groovy diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java index 1321ba1cc57..b8165c65140 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java @@ -56,10 +56,6 @@ public class AiAssistExtensionsSetupCommand extends AbstractOutputCommand defaultValue = "fail") private DigestMismatchAction onDigestMismatch; - @Option(names = {"-y", "--confirm"}, - descriptionKey = "fcli.ai-assist.extensions.confirm") - private boolean confirm; - @Option(names = {"--dry-run"}, descriptionKey = "fcli.ai-assist.extensions.dry-run") private boolean dryRun; diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java index b08fd9967e1..8c934eabb1a 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java @@ -40,10 +40,6 @@ public class AiAssistExtensionsUninstallCommand extends AbstractOutputCommand descriptionKey = "fcli.ai-assist.extensions.uninstall.dir") private String customDir; - @Option(names = {"-y", "--confirm"}, - descriptionKey = "fcli.ai-assist.extensions.confirm") - private boolean confirm; - @Option(names = {"--dry-run"}, descriptionKey = "fcli.ai-assist.extensions.dry-run") private boolean dryRun; diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java index ca14888ed6c..6e7fec755a3 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java @@ -60,8 +60,8 @@ public Integer call() { private String loadTemplate() { var templateResource = switch (type) { - case ssc -> "/com/fortify/cli/agent/mcp/config/mcp-http-config-ssc.yaml"; - case fod -> "/com/fortify/cli/agent/mcp/config/mcp-http-config-fod.yaml"; + case ssc -> "/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-ssc.yaml"; + case fod -> "/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-fod.yaml"; }; try ( var inputStream = getClass().getResourceAsStream(templateResource) ) { if ( inputStream == null ) { diff --git a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties index e8938fe2ac5..f5a2be05a48 100644 --- a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties @@ -48,7 +48,6 @@ fcli.ai-assist.extensions.source = Extensions source: local zip file or local di fcli.ai-assist.extensions.dir = Install content to a specific directory, bypassing assistant selection. Requires --content-types. Mutually exclusive with --assistants and --auto-detect. fcli.ai-assist.extensions.content-types = Filter by content type: skills, agents, plugins. Default: all. Required with --dir. fcli.ai-assist.extensions.on-digest-mismatch = Action when downloaded zip digest does not match tool definitions: warn, fail. Default: fail. -fcli.ai-assist.extensions.confirm = Skip confirmation prompt. fcli.ai-assist.extensions.dry-run = Show what would be done without performing any changes. fcli.ai-assist.extensions.detect = Run detection checks (glob patterns, command existence) to determine which assistants are present. Without this flag, detected column shows N/A. diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy index e0fbb7a6531..c56a6e5ffa7 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy @@ -49,7 +49,7 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { def "setup-no-target"() { when: - def result = Fcli.run("ai-assist extensions setup --dry-run -y", + def result = Fcli.run("ai-assist extensions setup --dry-run", {it.expectSuccess(false)}) then: verifyAll(result.stderr) { @@ -59,7 +59,7 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { def "setup-unknown-assistant"() { when: - def result = Fcli.run("ai-assist extensions setup --assistants unknown-assistant --dry-run -y", + def result = Fcli.run("ai-assist extensions setup --assistants unknown-assistant --dry-run", {it.expectSuccess(false)}) then: verifyAll(result.stderr) { @@ -70,7 +70,7 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { def "setup-dry-run"() { def targetDir = "${baseDir}/skills-dry-run" when: - def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills --dry-run -y", + def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills --dry-run", {it.expectZeroExitCode()}) then: verifyAll(result.stdout) { @@ -85,7 +85,7 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { def "setup-install"() { def targetDir = "${baseDir}/skills" when: - def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills -y", + def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills", {it.expectZeroExitCode()}) then: verifyAll(result.stdout) { @@ -112,7 +112,7 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { def "setup-idempotent"() { def targetDir = "${baseDir}/skills" when: - def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills -y", + def result = Fcli.run("ai-assist extensions setup --dir ${targetDir} --content-types skills", {it.expectZeroExitCode()}) then: verifyAll(result.stdout) { @@ -126,7 +126,7 @@ class AiAssistExtensionsSpec extends FcliBaseSpec { def "uninstall-dir"() { def targetDir = "${baseDir}/skills" when: - def result = Fcli.run("ai-assist extensions uninstall --dir ${targetDir} --content-types skills -y", + def result = Fcli.run("ai-assist extensions uninstall --dir ${targetDir} --content-types skills", {it.expectZeroExitCode()}) then: verifyAll(result.stdout) { diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistMcpSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistMcpSpec.groovy new file mode 100644 index 00000000000..a9c746b59a4 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistMcpSpec.groovy @@ -0,0 +1,64 @@ +package com.fortify.cli.ftest.core + +import java.nio.file.Files +import java.nio.file.Path + +import com.fortify.cli.ftest._common.Fcli +import com.fortify.cli.ftest._common.spec.FcliBaseSpec +import com.fortify.cli.ftest._common.spec.Prefix +import com.fortify.cli.ftest._common.spec.TempDir + +import spock.lang.Shared +import spock.lang.Stepwise + +@Prefix("core.ai-assist.mcp") @Stepwise +class AiAssistMcpSpec extends FcliBaseSpec { + @Shared @TempDir("ai-assist/mcp") String baseDir; + + def "create-http-config-ssc"() { + def configPath = "${baseDir}/mcp-http-config-ssc.yaml" + when: + def result = Fcli.run("ai-assist mcp create-http-config --type ssc --config ${configPath}", + {it.expectZeroExitCode()}) + then: + verifyAll { + Files.exists(Path.of(configPath)) + Files.readString(Path.of(configPath)).contains("ssc") + } + } + + def "create-http-config-fod"() { + def configPath = "${baseDir}/mcp-http-config-fod.yaml" + when: + def result = Fcli.run("ai-assist mcp create-http-config --type fod --config ${configPath}", + {it.expectZeroExitCode()}) + then: + verifyAll { + Files.exists(Path.of(configPath)) + Files.readString(Path.of(configPath)).contains("fod") + } + } + + def "create-http-config-no-overwrite"() { + def configPath = "${baseDir}/mcp-http-config-ssc.yaml" + when: + def result = Fcli.run("ai-assist mcp create-http-config --type ssc --config ${configPath}", + {it.expectSuccess(false)}) + then: + verifyAll(result.stderr) { + it.any { it.contains("already exists") } + } + } + + def "create-http-config-force-overwrite"() { + def configPath = "${baseDir}/mcp-http-config-ssc.yaml" + when: + def result = Fcli.run("ai-assist mcp create-http-config --type ssc --config ${configPath} --force", + {it.expectZeroExitCode()}) + then: + verifyAll { + Files.exists(Path.of(configPath)) + Files.readString(Path.of(configPath)).contains("ssc") + } + } +} From 39983d3d078f76083dc72cc3cc1640e8c250d3e1 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 12:44:46 +0200 Subject: [PATCH 47/55] chore: Various fixes & improvements --- .../AiAssistExtensionsConditionEvaluator.java | 27 +++++------ .../helper/AiAssistExtensionsInstaller.java | 8 ++-- .../AiAssistMCPCreateHttpConfigCommand.java | 4 +- .../MCPImportedActionMcpSpecsFactory.java | 10 ++-- .../JdkHttpServerMcpStatelessTransport.java | 10 ++-- .../http/MCPServerHttpConfigLoader.java | 2 +- .../runner/MCPResourceFcliRunnerFunction.java | 8 ++-- .../action/runner/ActionFunctionExecutor.java | 3 +- .../cli/util/FcliCommandExecutorFactory.java | 46 ++++++++++--------- .../common/cli/util/FcliExecutionContext.java | 3 +- .../cli/util/FcliExecutionContextHolder.java | 8 +++- .../cli/common/cli/util/StdioHelper.java | 6 +-- .../common/exception/FcliSimpleException.java | 10 +++- .../cli/cmd/FoDMicroserviceUpdateCommand.java | 3 +- .../helper/ToolDefinitionsHelper.java | 3 +- .../cmd/MCPServerStartDeprecatedCommand.java | 16 ++++--- 16 files changed, 96 insertions(+), 71 deletions(-) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java index 3b67b7cfea1..d1ab2c25daf 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java @@ -16,6 +16,7 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -32,14 +33,14 @@ public final class AiAssistExtensionsConditionEvaluator { private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsConditionEvaluator.class); - public AiAssistExtensionsConditionEvaluator() {} + private AiAssistExtensionsConditionEvaluator() {} /** * Evaluate a condition object (may be a map with a single condition or operator, * or a boolean literal for unconditional true/false). */ @SuppressWarnings("unchecked") - public boolean evaluate(Object condition) { + public static boolean evaluate(Object condition) { if (condition == null) { return true; } if (condition instanceof Boolean b) { return b; } if (condition instanceof Map map) { @@ -50,7 +51,7 @@ public boolean evaluate(Object condition) { } @SuppressWarnings("unchecked") - private boolean evaluateMap(Map map) { + private static boolean evaluateMap(Map map) { for (var entry : map.entrySet()) { var key = entry.getKey(); var value = entry.getValue(); @@ -62,9 +63,9 @@ private boolean evaluateMap(Map map) { case "command-exists": return evaluateCommandExists((String) value); case "any-of": - return evaluateAnyOf((java.util.List) value); + return evaluateAnyOf((List) value); case "all-of": - return evaluateAllOf((java.util.List) value); + return evaluateAllOf((List) value); case "not": return !evaluate(value); default: @@ -75,7 +76,7 @@ private boolean evaluateMap(Map map) { return true; } - private boolean evaluateDirExists(Object value) { + private static boolean evaluateDirExists(Object value) { if (value instanceof String s) { var resolved = AiAssistExtensionsPathResolver.resolvePath(s); return resolved != null && Files.isDirectory(resolved); @@ -92,7 +93,7 @@ private boolean evaluateDirExists(Object value) { * Value may be a plain string or a platform-specific map. */ @SuppressWarnings("unchecked") - private boolean evaluateGlobExists(Object value) { + private static boolean evaluateGlobExists(Object value) { String pattern; if (value instanceof String s) { pattern = s; @@ -127,7 +128,7 @@ private boolean evaluateGlobExists(Object value) { var parentPath = Path.of(parentBuilder.toString()); if (!Files.isDirectory(parentPath)) { return false; } // Build glob pattern from the remaining segments - var globTail = String.join("/", java.util.Arrays.copyOfRange(segments, globStart, segments.length)); + var globTail = String.join("/", Arrays.copyOfRange(segments, globStart, segments.length)); var matcher = FileSystems.getDefault().getPathMatcher("glob:" + globTail); try (var stream = Files.walk(parentPath, segments.length - globStart)) { return stream.anyMatch(p -> matcher.matches(parentPath.relativize(p))); @@ -142,7 +143,7 @@ private boolean evaluateGlobExists(Object value) { * for matching executables. On Windows, also checks PATHEXT extensions. * Does not spawn external processes (no which/where). */ - private boolean evaluateCommandExists(String command) { + private static boolean evaluateCommandExists(String command) { if (StringUtils.isBlank(command)) { return false; } var pathEnv = System.getenv("PATH"); if (StringUtils.isBlank(pathEnv)) { return false; } @@ -177,13 +178,13 @@ private static String[] getWindowsPathExtensions() { return result; } - private boolean evaluateAnyOf(List conditions) { + private static boolean evaluateAnyOf(List conditions) { if (conditions == null) { return false; } - return conditions.stream().anyMatch(this::evaluate); + return conditions.stream().anyMatch(AiAssistExtensionsConditionEvaluator::evaluate); } - private boolean evaluateAllOf(List conditions) { + private static boolean evaluateAllOf(List conditions) { if (conditions == null) { return false; } - return conditions.stream().allMatch(this::evaluate); + return conditions.stream().allMatch(AiAssistExtensionsConditionEvaluator::evaluate); } } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java index a7fdcbcf68d..82747e335b4 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java @@ -227,7 +227,6 @@ public static List listAssistants(b var distribution = AiAssistExtensionsSourceHandler.readDistributionDescriptor(true); if (distribution.getAssistants() == null) { return Collections.emptyList(); } - var conditionEvaluator = detect ? new AiAssistExtensionsConditionEvaluator() : null; var installations = loadInstallationsState(); var result = new ArrayList(); @@ -240,8 +239,8 @@ public static List listAssistants(b .toArray(String[]::new) : new String[0]; - String detected = conditionEvaluator != null - ? String.valueOf(conditionEvaluator.evaluate(assistant.getIfCondition())) + String detected = detect + ? String.valueOf(AiAssistExtensionsConditionEvaluator.evaluate(assistant.getIfCondition())) : "N/A"; var assistantInstallation = installations.getAssistants().get(id); @@ -299,9 +298,8 @@ private static Map selectAssistan result.put(id, assistant); } } else if (autoDetect) { - var evaluator = new AiAssistExtensionsConditionEvaluator(); for (var entry : distribution.getAssistants().entrySet()) { - if (evaluator.evaluate(entry.getValue().getIfCondition())) { + if (AiAssistExtensionsConditionEvaluator.evaluate(entry.getValue().getIfCondition())) { result.put(entry.getKey(), entry.getValue()); } } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java index 6e7fec755a3..f43576cbd2d 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.java @@ -52,7 +52,7 @@ public Integer call() { force ? new StandardOpenOption[] {StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING} : new StandardOpenOption[] {StandardOpenOption.CREATE_NEW}); } catch (IOException e) { - throw new FcliSimpleException("Error writing HTTP MCP config file: %s", outputPath); + throw new FcliSimpleException("Error writing HTTP MCP config file: " + outputPath, e); } System.out.printf("Created HTTP MCP config file: %s%n", outputPath); return 0; @@ -69,7 +69,7 @@ private String loadTemplate() { } return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { - throw new FcliSimpleException("Error reading HTTP MCP template resource: %s", templateResource); + throw new FcliSimpleException("Error reading HTTP MCP template resource: " + templateResource, e); } } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPImportedActionMcpSpecsFactory.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPImportedActionMcpSpecsFactory.java index 2d7d327e0e8..9d66fea498a 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPImportedActionMcpSpecsFactory.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPImportedActionMcpSpecsFactory.java @@ -15,6 +15,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.function.Supplier; import com.fasterxml.jackson.databind.JsonNode; @@ -25,6 +26,7 @@ import com.fortify.cli.common.action.helper.ActionLoaderHelper; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; +import com.fortify.cli.common.action.model.Action; import com.fortify.cli.common.action.model.ActionFunction; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; @@ -65,7 +67,7 @@ public ImportedSpecs create(Path importFile) { return new ImportedSpecs(tools, resourceTemplates); } - private SyncToolSpecification createToolSpec(com.fortify.cli.common.action.model.Action action, String functionName, ActionFunction function) { + private SyncToolSpecification createToolSpec(Action action, String functionName, ActionFunction function) { var executor = new ActionFunctionExecutor(action, function, frameSupplier); var toolName = "fcli_fn_" + functionName.replace('-', '_'); var schema = buildFunctionArgsSchema(function); @@ -89,7 +91,7 @@ private MCPToolFcliRunnerFunctionStreaming createStreamingRunner(ActionFunctionE return new MCPToolFcliRunnerFunctionStreaming(executor, jobManager, toolName); } - private SyncResourceTemplateSpecification createResourceTemplateSpec(com.fortify.cli.common.action.model.Action action, + private SyncResourceTemplateSpecification createResourceTemplateSpec(Action action, String functionName, ActionFunction function) { var resourceMeta = function.getMeta().get("mcp.resource"); @@ -153,7 +155,7 @@ private String getMetaString(JsonNode meta, String key) { } public record ImportedSpecs( - java.util.List tools, - java.util.List resourceTemplates + List tools, + List resourceTemplates ) {} } \ No newline at end of file diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java index 5d637db2982..37da881d122 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -160,14 +160,14 @@ private void handleExchange(HttpExchange exchange) throws IOException { } } + private static final long DEFAULT_MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024; // 10 MB + private byte[] readRequestBody(HttpExchange exchange) throws IOException { - if ( maxRequestBodyBytes <= 0 ) { - return exchange.getRequestBody().readAllBytes(); - } + var effectiveLimit = maxRequestBodyBytes > 0 ? maxRequestBodyBytes : DEFAULT_MAX_REQUEST_BODY_BYTES; // Read one extra byte to detect oversized bodies without loading them fully - var limit = (int) Math.min(maxRequestBodyBytes + 1, Integer.MAX_VALUE); + var limit = (int) Math.min(effectiveLimit + 1, Integer.MAX_VALUE); var bytes = exchange.getRequestBody().readNBytes(limit); - if ( bytes.length > maxRequestBodyBytes ) { + if ( bytes.length > effectiveLimit ) { sendPlainError(exchange, 413, "Request entity too large"); return null; } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfigLoader.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfigLoader.java index e5b40f5e2f3..15d8212a6f8 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfigLoader.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfigLoader.java @@ -52,7 +52,7 @@ public static MCPServerHttpConfig load(Path configPath) { } catch (FcliSimpleException e) { throw e; } catch (IOException e) { - throw new FcliSimpleException("Unable to read HTTP MCP config file: " + normalizedPath); + throw new FcliSimpleException("Unable to read HTTP MCP config file: " + normalizedPath, e); } } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java index 57e4df1def4..2dfebad689d 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java @@ -12,11 +12,13 @@ */ package com.fortify.cli.ai_assist.mcp.helper.runner; +import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.action.model.ActionStepRecordsForEach.IActionStepForEachProcessor; import com.fortify.cli.common.action.runner.ActionFunctionExecutor; import com.fortify.cli.common.json.JsonHelper; @@ -88,7 +90,7 @@ private static Pattern buildUriPattern(String uriTemplate) { } private static List extractParamNames(String uriTemplate) { - var result = new java.util.ArrayList(); + var result = new ArrayList(); var matcher = URI_TEMPLATE_PARAM.matcher(uriTemplate); while (matcher.find()) { result.add(matcher.group(1)); @@ -100,8 +102,8 @@ private String resultToString(Object result) { if (result instanceof JsonNode jn) { return jn.toPrettyString(); } - if (result instanceof com.fortify.cli.common.action.model.ActionStepRecordsForEach.IActionStepForEachProcessor processor) { - var records = new java.util.ArrayList(); + if (result instanceof IActionStepForEachProcessor processor) { + var records = new ArrayList(); processor.process(node -> { records.add(node); return true; diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java index dbe166eaa81..0fd516b0ed6 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.common.action.runner; +import java.util.Map; import java.util.function.Supplier; import com.fasterxml.jackson.databind.JsonNode; @@ -89,7 +90,7 @@ public Object execute(ObjectNode argsNode) { /** * Execute the function with named arguments from a Map-like structure. */ - public Object execute(java.util.Map args) { + public Object execute(Map args) { var argsNode = JsonHelper.getObjectMapper().createObjectNode(); if (args != null) { args.forEach((k, v) -> { diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliCommandExecutorFactory.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliCommandExecutorFactory.java index ccb8d9ab0bb..7af37ea9c26 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliCommandExecutorFactory.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliCommandExecutorFactory.java @@ -37,7 +37,6 @@ import lombok.Builder; import lombok.Data; -import lombok.NonNull; import picocli.CommandLine; import picocli.CommandLine.ExecutionException; import picocli.CommandLine.Model.CommandSpec; @@ -46,21 +45,12 @@ @Builder @Data public final class FcliCommandExecutorFactory { - @NonNull private final String cmd; + private final String[] args; private final Consumer recordConsumer; private final Consumer metadataConsumer; @Builder.Default private final OutputType stdoutOutputTypeIfRecordCollectionSupported = OutputType.show; @Builder.Default private final OutputType stdoutOutputTypeIfRecordCollectionNotSupported = OutputType.show; @Builder.Default private final OutputType stderrOutputType = OutputType.show; - - // Partial builder class; Lombok adds the generated field methods to this. - public static class FcliCommandExecutorFactoryBuilder { - /** Convenience method: sets the same stdout type regardless of whether the command supports record collection. */ - public FcliCommandExecutorFactoryBuilder stdoutOutputType(OutputType type) { - return stdoutOutputTypeIfRecordCollectionSupported(type) - .stdoutOutputTypeIfRecordCollectionNotSupported(type); - } - } private final Consumer onResult; // Always executed if fcli command didn't throw exception private final Consumer onSuccess; // Executed after onResult, if 0 exit code private final Consumer onFail; // Executed after onResult, if non-zero exit code @@ -73,11 +63,32 @@ private static final CommandLine getRootCommandLine() { } public final FcliCommandExecutor create() { - if ( StringUtils.isBlank(cmd) ) { - throw new FcliSimpleException("Fcli command to be run may not be blank"); + if ( args==null || args.length==0 ) { + throw new FcliSimpleException("Fcli command args may not be empty"); } return new FcliCommandExecutor(); } + + // Partial builder class; Lombok adds the generated field methods to this. + public static class FcliCommandExecutorFactoryBuilder { + /** Convenience: parse a command string into args. Strips leading {@code fcli} prefix. */ + public FcliCommandExecutorFactoryBuilder cmd(String cmd) { + return args(parseCmd(cmd)); + } + /** Convenience method: sets the same stdout type regardless of whether the command supports record collection. */ + public FcliCommandExecutorFactoryBuilder stdoutOutputType(OutputType type) { + return stdoutOutputTypeIfRecordCollectionSupported(type) + .stdoutOutputTypeIfRecordCollectionNotSupported(type); + } + + private static final String[] parseCmd(String cmd) { + var cmdWithoutFcli = cmd.replaceFirst("^fcli\s+", ""); + List argsList = new ArrayList(); + Matcher m = Pattern.compile("([^\"]\\S*|\".+?\")\\s*").matcher(cmdWithoutFcli); + while (m.find()) { argsList.add(m.group(1).replace("\"", "")); } + return argsList.toArray(String[]::new); + } + } public final class FcliCommandExecutor { private static final Logger LOG = LoggerFactory.getLogger(FcliCommandExecutor.class); @@ -86,7 +97,7 @@ public final class FcliCommandExecutor { private Result parseErrorResult = null; public FcliCommandExecutor() { - this.resolvedArgs = FcliVariableHelper.resolveVariables(parseArgs(cmd)); + this.resolvedArgs = FcliVariableHelper.resolveVariables(args); this.replicatedLeafCommandSpec = replicateLeafCommandSpecWithParents(parseArgs(resolvedArgs)); } @@ -262,12 +273,5 @@ private static CommandSpec replicateSpecForSubcommand(ParseResult pr) { } } - private static final String[] parseArgs(String args) { - var argsWithoutFcli = args.replaceFirst("^fcli\s+", ""); - List argsList = new ArrayList(); - Matcher m = Pattern.compile("([^\"]\\S*|\".+?\")\\s*").matcher(argsWithoutFcli); - while (m.find()) { argsList.add(m.group(1).replace("\"", "")); } - return argsList.toArray(String[]::new); - } } } \ No newline at end of file diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java index 3c5fc6ba4cb..9229adebebe 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContext.java @@ -17,6 +17,7 @@ import java.util.Base64; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import com.fortify.cli.common.crypto.helper.EncryptionHelper; import com.fortify.cli.common.log.LogMaskContext; @@ -72,7 +73,7 @@ public final class FcliExecutionContext implements AutoCloseable { // Encryption helper used for encrypt/decrypt in this execution. Default to global DEFAULT. private volatile EncryptionHelper encryptionHelper = EncryptionHelper.DEFAULT; // Set of absolute file paths that were saved using ephemeral encryption during this execution - private final Set ephemeralEncryptedFiles = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private final Set ephemeralEncryptedFiles = ConcurrentHashMap.newKeySet(); public FcliExecutionContext() { this(new FcliIsolationScope(), new FcliActionState(), new LogMaskContext()); diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java index ea19ade8a59..42f5c33f952 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionContextHolder.java @@ -104,8 +104,12 @@ public static FcliExecutionContext current() { } /** - * Look up a transient session descriptor by type, searching from top to bottom - * through the current thread's execution-context stack. + * Look up a transient session descriptor by type from the current context's + * isolation scope. Since child contexts created via + * {@link FcliExecutionContext#createChild()} share the same + * {@link FcliIsolationScope} reference as their parent, transient session + * descriptors registered in the parent are visible to all children without + * requiring stack traversal. */ public static ISessionDescriptor getTransientSessionDescriptor(String type) { return current().getIsolationScope().getTransientSessionDescriptor(type); diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java index d808cc9ea11..c3762136512 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java @@ -80,7 +80,7 @@ public static synchronized void install() { if ( installed ) return; // Detect ANSI capability before replacing streams: the delegating/masking // wrappers installed below can interfere with terminal-based ANSI probing. - ansi = Ansi.AUTO.enabled() ? Ansi.ON : Ansi.AUTO; + ansi = Ansi.AUTO.enabled() ? Ansi.ON : Ansi.OFF; rawOut = System.out; rawErr = System.err; installThread = Thread.currentThread(); @@ -118,8 +118,8 @@ public static synchronized void uninstall() { /** * Return the resolved ANSI mode, detected before streams were replaced. - * Always returns {@link Ansi#ON} or {@link Ansi#AUTO} (never {@link Ansi#OFF} - * unless the terminal reported no ANSI support before installation). + * Returns {@link Ansi#ON} if the terminal supports ANSI, {@link Ansi#OFF} + * otherwise. Before {@link #install()} is called, returns {@link Ansi#AUTO}. */ public static Ansi getAnsi() { return ansi; } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java index e9e29450d3b..c8f087d305e 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java @@ -12,6 +12,8 @@ */ package com.fortify.cli.common.exception; +import java.io.IOException; + import org.apache.commons.lang3.exception.ExceptionUtils; import com.formkiq.graalvm.annotations.Reflectable; @@ -62,11 +64,17 @@ public String getStackTraceString() { private String getCauseAsString() { var cause = getCause(); if ( cause==null ) { return ""; } - var causeAsString = (cause instanceof ParameterException || cause instanceof FcliSimpleException) + var causeAsString = isSummarizable(cause) ? getSummary(cause) : ExceptionUtils.getStackTrace(cause); return String.format("\nCaused by: %s", causeAsString); } + + private static boolean isSummarizable(Throwable cause) { + return cause instanceof ParameterException + || cause instanceof FcliSimpleException + || cause instanceof IOException; + } private static String getSummary(Throwable e) { var firstElt = getFirstStackTraceElement(e); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java index 99aa47ab540..72e04d504ad 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceUpdateCommand.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.fod.microservice.cli.cmd; +import java.util.ArrayList; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; @@ -49,7 +50,7 @@ public class FoDMicroserviceUpdateCommand extends AbstractFoDJsonNodeOutputComma public JsonNode getJsonNode(UnirestInstance unirest) { FoDMicroserviceDescriptor msDescriptor = microserviceResolver.getMicroserviceDescriptor(unirest, true); var attrHelper = new FoDAttributeDefinitionHelper(unirest); - java.util.ArrayList msAttrsCurrent = msDescriptor.getAttributes(); + ArrayList msAttrsCurrent = msDescriptor.getAttributes(); Map attributeUpdates = msAttrsUpdate.getAttributes(); JsonNode jsonAttrs = attrHelper.buildAttributesNodeForUpdate(null, msAttrsCurrent, attributeUpdates, false); FoDMicroserviceUpdateRequest msUpdateRequest = FoDMicroserviceUpdateRequest.builder() diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java index ffbdd3f79c5..4babff5a950 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java @@ -16,6 +16,7 @@ import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.FileSystems; import java.nio.file.Files; @@ -400,7 +401,7 @@ public static final String readExtraFile(String fileName) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { if (fileName.equals(entry.getName())) { - return new String(zis.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + return new String(zis.readAllBytes(), StandardCharsets.UTF_8); } } throw new FcliSimpleException("Extra file not found in tool definitions: " + fileName); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java index ba03a437133..29730b46a4a 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java @@ -12,7 +12,9 @@ */ package com.fortify.cli.util.mcp_server.cli.cmd; +import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; @@ -35,17 +37,17 @@ public class MCPServerStartDeprecatedCommand extends AbstractRunnableCommand imp @Override public Integer call() { - var cmd = "fcli ai-assist mcp start-stdio"; - if ( delegatedArgs != null && !delegatedArgs.isEmpty() ) { - cmd += " " + String.join(" ", delegatedArgs); - } + var baseArgs = new String[]{"ai-assist", "mcp", "start-stdio"}; + var allArgs = delegatedArgs != null && !delegatedArgs.isEmpty() + ? Stream.concat(Arrays.stream(baseArgs), delegatedArgs.stream()).toArray(String[]::new) + : baseArgs; log.warn("The 'fcli util mcp-server start' command is deprecated; please use 'fcli ai-assist mcp start-stdio'"); var result = FcliCommandExecutorFactory.builder() - .cmd(cmd) + .args(allArgs) .stdoutOutputType(OutputType.show) .stderrOutputType(OutputType.show) - .onFail(r -> {}) - .build().create().execute(); + .onFail(r -> {}) + .build().create().execute(); if ( result.getExitCode() != 0 && StringUtils.isNotBlank(result.getErr()) ) { log.debug("Delegated command failed: {}", result.getErr()); } From 1becd84f1e1206c33a3168c69992cb0cce17a934 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 13:02:09 +0200 Subject: [PATCH 48/55] ci: Avoid duplicate workflow runs --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ .github/workflows/fortify-analysis.yml | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d5d1f90cc4..bba8a88b170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,32 @@ env: graal_java_version: 21 jobs: + check-duplicate: + name: Check for duplicate run + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.check.outputs.should_skip }} + steps: + - id: check + name: Skip push run if PR exists + if: github.event_name == 'push' + run: | + pr_count=$(gh api repos/${{ github.repository }}/pulls \ + --jq '[.[] | select(.head.ref == "${{ github.ref_name }}" and .state == "open")] | length') + if [ "$pr_count" -gt 0 ]; then + echo "Open PR found for branch ${{ github.ref_name }}, skipping push-triggered run" + echo "should_skip=true" >> "$GITHUB_OUTPUT" + else + echo "No open PR found for branch ${{ github.ref_name }}, proceeding" + echo "should_skip=false" >> "$GITHUB_OUTPUT" + fi + env: + GH_TOKEN: ${{ github.token }} + create-release: name: create-release + needs: check-duplicate + if: needs.check-duplicate.outputs.should_skip != 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/fortify-analysis.yml b/.github/workflows/fortify-analysis.yml index 20348f4ad94..e1341a596ae 100644 --- a/.github/workflows/fortify-analysis.yml +++ b/.github/workflows/fortify-analysis.yml @@ -13,7 +13,31 @@ permissions: security-events: write jobs: + check-duplicate: + name: Check for duplicate run + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.check.outputs.should_skip }} + steps: + - id: check + name: Skip push run if PR exists + if: github.event_name == 'push' + run: | + pr_count=$(gh api repos/${{ github.repository }}/pulls \ + --jq '[.[] | select(.head.ref == "${{ github.ref_name }}" and .state == "open")] | length') + if [ "$pr_count" -gt 0 ]; then + echo "Open PR found for branch ${{ github.ref_name }}, skipping push-triggered run" + echo "should_skip=true" >> "$GITHUB_OUTPUT" + else + echo "No open PR found for branch ${{ github.ref_name }}, proceeding" + echo "should_skip=false" >> "$GITHUB_OUTPUT" + fi + env: + GH_TOKEN: ${{ github.token }} + FoD-Scan: + needs: check-duplicate + if: needs.check-duplicate.outputs.should_skip != 'true' uses: fortify/.github/.github/workflows/fortify-analysis.yml@main with: java-version: '17' From 5a45acd496fbfe74fa030997323f44c70f214cd2 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 13:16:48 +0200 Subject: [PATCH 49/55] ci: Avoid duplicate workflow runs (improvement) --- .github/workflows/ci.yml | 29 ++++++-------------------- .github/workflows/fortify-analysis.yml | 29 ++++++-------------------- 2 files changed, 12 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bba8a88b170..fe6cb8fbedd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,32 +26,15 @@ env: graal_java_version: 21 jobs: - check-duplicate: - name: Check for duplicate run - runs-on: ubuntu-latest - outputs: - should_skip: ${{ steps.check.outputs.should_skip }} - steps: - - id: check - name: Skip push run if PR exists - if: github.event_name == 'push' - run: | - pr_count=$(gh api repos/${{ github.repository }}/pulls \ - --jq '[.[] | select(.head.ref == "${{ github.ref_name }}" and .state == "open")] | length') - if [ "$pr_count" -gt 0 ]; then - echo "Open PR found for branch ${{ github.ref_name }}, skipping push-triggered run" - echo "should_skip=true" >> "$GITHUB_OUTPUT" - else - echo "No open PR found for branch ${{ github.ref_name }}, proceeding" - echo "should_skip=false" >> "$GITHUB_OUTPUT" - fi - env: - GH_TOKEN: ${{ github.token }} + check-duplicate-run: + uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main + with: + workflow_name: Build and release create-release: name: create-release - needs: check-duplicate - if: needs.check-duplicate.outputs.should_skip != 'true' + needs: check-duplicate-run + if: needs.check-duplicate-run.outputs.should_skip != 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/fortify-analysis.yml b/.github/workflows/fortify-analysis.yml index e1341a596ae..99839d5aa96 100644 --- a/.github/workflows/fortify-analysis.yml +++ b/.github/workflows/fortify-analysis.yml @@ -13,31 +13,14 @@ permissions: security-events: write jobs: - check-duplicate: - name: Check for duplicate run - runs-on: ubuntu-latest - outputs: - should_skip: ${{ steps.check.outputs.should_skip }} - steps: - - id: check - name: Skip push run if PR exists - if: github.event_name == 'push' - run: | - pr_count=$(gh api repos/${{ github.repository }}/pulls \ - --jq '[.[] | select(.head.ref == "${{ github.ref_name }}" and .state == "open")] | length') - if [ "$pr_count" -gt 0 ]; then - echo "Open PR found for branch ${{ github.ref_name }}, skipping push-triggered run" - echo "should_skip=true" >> "$GITHUB_OUTPUT" - else - echo "No open PR found for branch ${{ github.ref_name }}, proceeding" - echo "should_skip=false" >> "$GITHUB_OUTPUT" - fi - env: - GH_TOKEN: ${{ github.token }} + check-duplicate-run: + uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main + with: + workflow_name: Fortify on Demand Scan FoD-Scan: - needs: check-duplicate - if: needs.check-duplicate.outputs.should_skip != 'true' + needs: check-duplicate-run + if: needs.check-duplicate-run.outputs.should_skip != 'true' uses: fortify/.github/.github/workflows/fortify-analysis.yml@main with: java-version: '17' From ddf38e8882998c49dffffa8c2c44accca3a3f121 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 13:23:05 +0200 Subject: [PATCH 50/55] ci: Fix workflow permissions --- .github/workflows/ci.yml | 4 ++-- .github/workflows/fortify-analysis.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe6cb8fbedd..d126c164567 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ env: jobs: check-duplicate-run: uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main - with: - workflow_name: Build and release + permissions: + actions: write create-release: name: create-release diff --git a/.github/workflows/fortify-analysis.yml b/.github/workflows/fortify-analysis.yml index 99839d5aa96..fc471947494 100644 --- a/.github/workflows/fortify-analysis.yml +++ b/.github/workflows/fortify-analysis.yml @@ -15,8 +15,8 @@ permissions: jobs: check-duplicate-run: uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main - with: - workflow_name: Fortify on Demand Scan + permissions: + actions: write FoD-Scan: needs: check-duplicate-run From 043dd897a99989408194214364cf17800283a25a Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 13:37:42 +0200 Subject: [PATCH 51/55] ci: Workflow improvements/fixes --- .github/workflows/ci.yml | 3 +-- .github/workflows/fortify-analysis.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d126c164567..32140eb0d82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ name: Build and release +run-name: "Build and release (${{ github.event_name }})" on: workflow_dispatch: pull_request: @@ -28,8 +29,6 @@ env: jobs: check-duplicate-run: uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main - permissions: - actions: write create-release: name: create-release diff --git a/.github/workflows/fortify-analysis.yml b/.github/workflows/fortify-analysis.yml index fc471947494..d46f41641fc 100644 --- a/.github/workflows/fortify-analysis.yml +++ b/.github/workflows/fortify-analysis.yml @@ -1,4 +1,5 @@ name: Fortify on Demand Scan +run-name: "Fortify on Demand Scan (${{ github.event_name }})" on: workflow_dispatch: @@ -15,8 +16,6 @@ permissions: jobs: check-duplicate-run: uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main - permissions: - actions: write FoD-Scan: needs: check-duplicate-run From 999110abdb7674a35752d9c338048df5bd20766c Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 14:25:05 +0200 Subject: [PATCH 52/55] chore: Improve glob matching --- .../AiAssistExtensionsConditionEvaluator.java | 64 ++--- .../AiAssistExtensionsPathResolver.java | 6 +- .../fortify/cli/common/util/FileUtils.java | 220 +++++++----------- 3 files changed, 103 insertions(+), 187 deletions(-) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java index d1ab2c25daf..369df40889b 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.java @@ -12,19 +12,19 @@ */ package com.fortify.cli.ai_assist.extensions.helper; -import java.io.IOException; -import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; import java.util.List; import java.util.Map; import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.SystemUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fortify.cli.common.util.EnvHelper; +import com.fortify.cli.common.util.FileUtils; +import com.fortify.cli.common.util.PlatformHelper; + /** * Evaluates declarative conditions from the extensions-distribution.yaml descriptor. * Supports simple conditions (dir-exists, glob-exists, command-exists) and @@ -89,50 +89,19 @@ private static boolean evaluateDirExists(Object value) { /** * Check if a glob pattern (with tilde/env-var expansion) matches at least one - * existing directory. Useful for patterns like {@code ~/.vscode/extensions/github.copilot-*}. + * existing path. Useful for patterns like {@code ~/.vscode/extensions/github.copilot-*}. * Value may be a plain string or a platform-specific map. */ - @SuppressWarnings("unchecked") private static boolean evaluateGlobExists(Object value) { - String pattern; - if (value instanceof String s) { - pattern = s; - } else if (value instanceof Map map) { - var platformKey = SystemUtils.IS_OS_WINDOWS ? "windows" - : SystemUtils.IS_OS_MAC ? "darwin" : "linux"; - pattern = (String) ((Map) map).get(platformKey); - } else { - return false; - } + var pattern = resolvePlatformString(value); if (pattern == null) { return false; } if (pattern.startsWith("~/")) { - pattern = System.getProperty("user.home") + pattern.substring(1); - } - // Split into parent dir (no globs) and the glob tail - // Walk segments to find where the first glob char appears - var segments = pattern.split("/"); - var parentBuilder = new StringBuilder(); - int globStart = -1; - for (int i = 0; i < segments.length; i++) { - if (segments[i].contains("*") || segments[i].contains("?") || segments[i].contains("[")) { - globStart = i; - break; - } - if (i > 0) { parentBuilder.append('/'); } - parentBuilder.append(segments[i]); - } - if (globStart < 0) { - // No glob chars — just check directory existence - return Files.isDirectory(Path.of(pattern)); + pattern = EnvHelper.getUserHome() + pattern.substring(1); } - var parentPath = Path.of(parentBuilder.toString()); - if (!Files.isDirectory(parentPath)) { return false; } - // Build glob pattern from the remaining segments - var globTail = String.join("/", Arrays.copyOfRange(segments, globStart, segments.length)); - var matcher = FileSystems.getDefault().getPathMatcher("glob:" + globTail); - try (var stream = Files.walk(parentPath, segments.length - globStart)) { - return stream.anyMatch(p -> matcher.matches(parentPath.relativize(p))); - } catch (IOException e) { + try { + return FileUtils.processGlobPathStream(pattern, p -> true, + stream -> stream.findAny().isPresent()); + } catch (Exception e) { LOG.debug("Error evaluating glob '{}': {}", value, e.getMessage()); return false; } @@ -150,7 +119,7 @@ private static boolean evaluateCommandExists(String command) { var pathSep = System.getProperty("path.separator"); var dirs = pathEnv.split(pathSep); // On Windows, try command as-is plus each PATHEXT extension - var extensions = SystemUtils.IS_OS_WINDOWS + var extensions = PlatformHelper.isWindows() ? getWindowsPathExtensions() : new String[]{""}; for (var dir : dirs) { @@ -187,4 +156,13 @@ private static boolean evaluateAllOf(List conditions) { if (conditions == null) { return false; } return conditions.stream().allMatch(AiAssistExtensionsConditionEvaluator::evaluate); } + + @SuppressWarnings("unchecked") + private static String resolvePlatformString(Object value) { + if (value instanceof String s) { return s; } + if (value instanceof Map map) { + return (String) ((Map) map).get(PlatformHelper.getOSString()); + } + return null; + } } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java index 42a7c728952..a6b370d4294 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java @@ -19,9 +19,9 @@ import java.util.Objects; import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.SystemUtils; import com.fortify.cli.common.util.EnvHelper; +import com.fortify.cli.common.util.PlatformHelper; /** * Resolves target-dir values from the descriptor: tilde expansion, @@ -100,8 +100,6 @@ private static String expandEnvVars(String path) { } static String getPlatformKey() { - if (SystemUtils.IS_OS_WINDOWS) { return "windows"; } - if (SystemUtils.IS_OS_MAC) { return "darwin"; } - return "linux"; + return PlatformHelper.getOSString(); } } diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java index fe3dda16276..9caedd65073 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/util/FileUtils.java @@ -35,7 +35,6 @@ import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; -import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.zip.GZIPInputStream; import java.util.zip.ZipEntry; @@ -292,149 +291,71 @@ public static final String pathToString(Path path, char separatorChar) { } /** - * Process files matching an Ant-style glob pattern using a stream processor function. - * Pattern supports: - * - {@code *} matches any characters within a single path segment - * - {@code **} matches zero or more directory levels - * Example: {@code "lib/*.jar"} matches all JARs in lib, {@code "**\/*.jar"} matches all JARs recursively - * - * The stream is automatically closed after the processor function completes. - * Only regular files are included in the stream. + * Process files matching a glob pattern using a stream processor function. + * Uses Java NIO {@link java.nio.file.PathMatcher} glob syntax: + * {@code *} matches within a single path segment, {@code **} matches across directories. * * @param Return type of the processor function * @param baseDir Base directory to search from - * @param globPattern Ant-style glob pattern + * @param globPattern Glob pattern relative to baseDir * @param maxDepth Maximum directory depth to search * @param streamProcessor Function to process the stream of matching paths * @return Result from the stream processor function - * @throws IOException if directory traversal fails */ @SneakyThrows public static final R processMatchingFileStream(Path baseDir, String globPattern, int maxDepth, Function, R> streamProcessor) { - var pattern = compileAntGlobPattern(globPattern); - return processMatchingFileStream(baseDir, pattern, maxDepth, streamProcessor); - } - - /** - * Process files matching a compiled Pattern using a stream processor function. - * Only regular files are included in the stream. - * - * The stream is automatically closed after the processor function completes. - * - * @param Return type of the processor function - * @param baseDir Base directory to search from - * @param pattern Compiled pattern to match against relative paths - * @param maxDepth Maximum directory depth to search - * @param streamProcessor Function to process the stream of matching paths - * @return Result from the stream processor function - * @throws IOException if directory traversal fails - */ - @SneakyThrows - public static final R processMatchingFileStream(Path baseDir, Pattern pattern, int maxDepth, - Function, R> streamProcessor) { - return processMatchingStream(baseDir, pattern, maxDepth, Files::isRegularFile, streamProcessor); + return processMatchingStream(baseDir, globPattern, maxDepth, Files::isRegularFile, streamProcessor); } /** - * Process directories matching an Ant-style glob pattern using a stream processor function. - * Pattern supports: - * - {@code *} matches any characters within a single path segment - * - {@code **} matches zero or more directory levels - * - * The stream is automatically closed after the processor function completes. - * Only directories are included in the stream. + * Process directories matching a glob pattern using a stream processor function. + * Uses Java NIO {@link java.nio.file.PathMatcher} glob syntax. * * @param Return type of the processor function * @param baseDir Base directory to search from - * @param globPattern Ant-style glob pattern + * @param globPattern Glob pattern relative to baseDir * @param maxDepth Maximum directory depth to search * @param streamProcessor Function to process the stream of matching paths * @return Result from the stream processor function - * @throws IOException if directory traversal fails */ @SneakyThrows public static final R processMatchingDirStream(Path baseDir, String globPattern, int maxDepth, Function, R> streamProcessor) { - var pattern = compileAntGlobPattern(globPattern); - return processMatchingDirStream(baseDir, pattern, maxDepth, streamProcessor); + return processMatchingStream(baseDir, globPattern, maxDepth, Files::isDirectory, streamProcessor); } /** - * Process directories matching a compiled Pattern using a stream processor function. - * Only directories are included in the stream. - * - * The stream is automatically closed after the processor function completes. + * Process paths matching a glob pattern using a stream processor function. + * Uses Java NIO {@link java.nio.file.PathMatcher} glob syntax: + * {@code *} matches within a single path segment, {@code **} matches across directories, + * {@code ?} matches a single character, {@code [...]} matches character classes, + * {@code {...}} matches alternatives. * * @param Return type of the processor function * @param baseDir Base directory to search from - * @param pattern Compiled pattern to match against relative paths - * @param maxDepth Maximum directory depth to search - * @param streamProcessor Function to process the stream of matching paths - * @return Result from the stream processor function - * @throws IOException if directory traversal fails - */ - @SneakyThrows - public static final R processMatchingDirStream(Path baseDir, Pattern pattern, int maxDepth, - Function, R> streamProcessor) { - return processMatchingStream(baseDir, pattern, maxDepth, Files::isDirectory, streamProcessor); - } - - /** - * Process paths matching an Ant-style glob pattern using a stream processor function. - * Pattern supports: - * - {@code *} matches any characters within a single path segment - * - {@code **} matches zero or more directory levels - * - * The stream is automatically closed after the processor function completes. - * - * @param Return type of the processor function - * @param baseDir Base directory to search from - * @param globPattern Ant-style glob pattern + * @param globPattern Glob pattern relative to baseDir * @param maxDepth Maximum directory depth to search * @param pathFilter Predicate to filter paths (e.g., Files::isRegularFile, Files::isDirectory) * @param streamProcessor Function to process the stream of matching paths * @return Result from the stream processor function - * @throws IOException if directory traversal fails */ @SneakyThrows public static final R processMatchingStream(Path baseDir, String globPattern, int maxDepth, Predicate pathFilter, Function, R> streamProcessor) { - var pattern = compileAntGlobPattern(globPattern); - return processMatchingStream(baseDir, pattern, maxDepth, pathFilter, streamProcessor); - } - - /** - * Process paths matching a compiled Pattern using a stream processor function. - * - * The stream is automatically closed after the processor function completes. - * Paths are matched as relative paths from baseDir with forward slashes. - * - * @param Return type of the processor function - * @param baseDir Base directory to search from - * @param pattern Compiled pattern to match against relative paths - * @param maxDepth Maximum directory depth to search - * @param pathFilter Predicate to filter paths (e.g., Files::isRegularFile, Files::isDirectory) - * @param streamProcessor Function to process the stream of matching paths - * @return Result from the stream processor function - * @throws IOException if directory traversal fails - */ - @SneakyThrows - public static final R processMatchingStream(Path baseDir, Pattern pattern, int maxDepth, - Predicate pathFilter, Function, R> streamProcessor) { if (baseDir == null || !Files.isDirectory(baseDir)) { throw new FcliSimpleException("Base directory must be a valid directory"); } if (pathFilter == null) { throw new FcliSimpleException("Path filter must not be null"); } - + var pathMatcher = baseDir.getFileSystem().getPathMatcher("glob:" + normalizeGlobForRootMatch(globPattern)); try (Stream paths = Files.walk(baseDir, maxDepth)) { Stream filtered = paths .filter(p -> !p.equals(baseDir)) .filter(pathFilter) .map(baseDir::relativize) - .filter(p -> pattern.matcher(pathToString(p, '/')).matches()) + .filter(pathMatcher::matches) .map(baseDir::resolve); return streamProcessor.apply(filtered); @@ -442,54 +363,73 @@ public static final R processMatchingStream(Path baseDir, Pattern pattern, i } /** - * Convert an Ant-style glob pattern to a compiled regex Pattern. - * Supports: - * - {@code *} matches any characters within a single path segment (does not match /) - * - {@code **} matches zero or more directory levels + * Process paths matching a glob path that may contain glob characters at any position. + * Automatically splits the path into a base directory (the longest prefix without + * glob characters) and a glob pattern, then walks the base directory matching against + * the glob pattern. + *

    + * If the path contains no glob characters, checks if the exact path exists and matches + * the filter. Returns an empty stream (not an exception) if the base directory does + * not exist. + *

    + * Glob characters are: {@code *}, {@code ?}, {@code [}, {. + * If the glob contains {@code **}, traversal depth is unlimited; otherwise it + * is limited to the number of path segments in the glob tail. * - * @param globPattern Ant-style glob pattern (e.g., "lib/*.jar", "**{@literal /}*.jar") - * @return Compiled Pattern + * @param Return type of the processor function + * @param globPath Absolute or relative path that may contain glob characters + * @param pathFilter Predicate to filter paths (e.g., Files::isRegularFile, Files::isDirectory) + * @param streamProcessor Function to process the stream of matching paths + * @return Result from the stream processor function */ - private static Pattern compileAntGlobPattern(String globPattern) { - if (globPattern == null || globPattern.isEmpty()) { - throw new FcliSimpleException("Glob pattern cannot be null or empty"); + @SneakyThrows + public static final R processGlobPathStream(String globPath, + Predicate pathFilter, Function, R> streamProcessor) { + if (globPath == null || globPath.isBlank()) { + throw new FcliSimpleException("Glob path cannot be null or empty"); } - - // Normalize path separators to forward slash - String normalized = globPattern.replace('\\', '/'); - - // Build regex manually by processing character by character - StringBuilder regex = new StringBuilder(); - int length = normalized.length(); - - for (int i = 0; i < length; i++) { - char c = normalized.charAt(i); - - if (c == '*') { - if (i + 1 < length && normalized.charAt(i + 1) == '*') { - // Found ** - if (i + 2 < length && normalized.charAt(i + 2) == '/') { - // **/ matches zero or more path segments - regex.append("(?:.*/)?"); - i += 2; // Skip the ** and / - } else { - // ** matches anything - regex.append(".*"); - i++; // Skip the second * - } - } else { - // Single * matches anything except / - regex.append("[^/]*"); - } - } else { - // Escape regex special characters except * which we already handled - if (".^$+?()[]{}|\\".indexOf(c) >= 0) { - regex.append('\\'); - } - regex.append(c); + var normalized = globPath.replace('\\', '/'); + int firstGlob = indexOfFirstGlobChar(normalized); + if (firstGlob < 0) { + // No glob characters — check exact path + var path = Path.of(globPath); + return streamProcessor.apply( + Files.exists(path) && pathFilter.test(path) + ? Stream.of(path) : Stream.empty()); + } + int lastSep = normalized.lastIndexOf('/', firstGlob); + var baseDirStr = lastSep > 0 ? normalized.substring(0, lastSep) + : lastSep == 0 ? "/" : "."; + var baseDir = Path.of(baseDirStr); + if (!Files.isDirectory(baseDir)) { + return streamProcessor.apply(Stream.empty()); + } + var globTail = normalized.substring(lastSep + 1); + int maxDepth = globTail.contains("**") + ? Integer.MAX_VALUE + : (int) globTail.chars().filter(c -> c == '/').count() + 1; + return processMatchingStream(baseDir, globTail, maxDepth, pathFilter, streamProcessor); + } + + private static int indexOfFirstGlobChar(String path) { + for (int i = 0; i < path.length(); i++) { + if ("*?[{".indexOf(path.charAt(i)) >= 0) { + return i; } } - - return Pattern.compile(regex.toString()); + return -1; + } + + /** + * NIO PathMatcher's {@code **}{@code /X} requires at least one directory level + * before X, unlike common glob conventions where {@code **}{@code /} can match zero + * levels. This wraps such patterns with an alternation so root-level paths also match. + * For example, {@code **}{@code /*.jar} becomes {*.jar,**/*.jar}. + */ + private static String normalizeGlobForRootMatch(String globPattern) { + if (globPattern.startsWith("**/")) { + return "{" + globPattern.substring(3) + "," + globPattern + "}"; + } + return globPattern; } } From 28a32a3a5a3ca02f69c81d091e436ac6338d5778 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 14:28:50 +0200 Subject: [PATCH 53/55] chore: Improve code quality --- .../cli/cmd/AiAssistMCPStartHttpCommand.java | 95 ++++++++++++------- .../JdkHttpServerMcpStatelessTransport.java | 75 +++++++++------ 2 files changed, 110 insertions(+), 60 deletions(-) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java index af3c6e2e440..a1965faaf1b 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java @@ -17,12 +17,14 @@ import java.util.ArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.ai_assist.mcp.helper.MCPImportedActionMcpSpecsFactory; import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfig; import com.fortify.cli.ai_assist.mcp.helper.http.JdkHttpServerMcpStatelessTransport; import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpAuthHeaderParser; import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfigLoader; @@ -63,41 +65,60 @@ public class AiAssistMCPStartHttpCommand extends AbstractRunnableCommand impleme @Override public Integer call() throws Exception { - // Suppress progress output — HTTP server has no stdio protocol channel to protect, - // so progress messages on stdout/stderr are unwanted console noise - StdioHelper.setProgressOut(null); - StdioHelper.setProgressErr(null); - + suppressProgressOutput(); var config = MCPServerHttpConfigLoader.load(configPath); + var asyncJobManager = new AsyncJobManager(AsyncJobManager.Config.builder() + .bgThreads(config.getJobs().getAsyncBgThreads()).build()); + var jobManager = createJobManager(config, asyncJobManager); + var authHeaderParser = new MCPServerHttpAuthHeaderParser(config); + var sessionDescriptorResolver = new MCPServerHttpSessionDescriptorResolver(config); + var scopeCleanupScheduler = scheduleScopeCleanup(config, sessionDescriptorResolver); + var specs = collectMcpSpecs(config, jobManager, sessionDescriptorResolver, authHeaderParser); + var transport = createTransport(config); + buildAndStartServer(config, transport, specs); + awaitShutdown(transport, asyncJobManager, scopeCleanupScheduler, sessionDescriptorResolver); + return 0; + } - var safeReturnMillis = PERIOD_HELPER.parsePeriodToMillis(config.getJobs().getSafeReturn()); - var progressIntervalMillis = PERIOD_HELPER.parsePeriodToMillis(config.getJobs().getProgressInterval()); - if ( safeReturnMillis <= 0 ) { - safeReturnMillis = 25000; - } - if ( progressIntervalMillis <= 0 ) { - progressIntervalMillis = 500; - } + private void suppressProgressOutput() { + StdioHelper.setProgressOut(null); + StdioHelper.setProgressErr(null); + } - var asyncJobManager = new AsyncJobManager(AsyncJobManager.Config.builder().bgThreads(config.getJobs().getAsyncBgThreads()).build()); - var jobManager = new MCPJobManager( - config.getJobs().getWorkThreads(), - config.getJobs().getProgressThreads(), + private MCPJobManager createJobManager(MCPServerHttpConfig config, AsyncJobManager asyncJobManager) { + var jobsConfig = config.getJobs(); + var safeReturnMillis = PERIOD_HELPER.parsePeriodToMillis(jobsConfig.getSafeReturn()); + var progressIntervalMillis = PERIOD_HELPER.parsePeriodToMillis(jobsConfig.getProgressInterval()); + if (safeReturnMillis <= 0) { safeReturnMillis = 25000; } + if (progressIntervalMillis <= 0) { progressIntervalMillis = 500; } + return new MCPJobManager( + jobsConfig.getWorkThreads(), + jobsConfig.getProgressThreads(), safeReturnMillis, progressIntervalMillis, - asyncJobManager - ); + asyncJobManager); + } - var authHeaderParser = new MCPServerHttpAuthHeaderParser(config); - var sessionDescriptorResolver = new MCPServerHttpSessionDescriptorResolver(config); - var scopeCleanupScheduler = Executors.newSingleThreadScheduledExecutor( + private ScheduledExecutorService scheduleScopeCleanup(MCPServerHttpConfig config, + MCPServerHttpSessionDescriptorResolver sessionDescriptorResolver) { + var scheduler = Executors.newSingleThreadScheduledExecutor( r -> new Thread(r, "mcp-http-scope-cleanup")); - sessionDescriptorResolver.scheduleCleanup(config.getJobs().getIsolationScopeTtlInMillis(), scopeCleanupScheduler); + sessionDescriptorResolver.scheduleCleanup(config.getJobs().getIsolationScopeTtlInMillis(), scheduler); + return scheduler; + } + + private record McpSpecs( + ArrayList tools, + ArrayList resourceTemplates) {} + + private McpSpecs collectMcpSpecs(MCPServerHttpConfig config, MCPJobManager jobManager, + MCPServerHttpSessionDescriptorResolver sessionDescriptorResolver, + MCPServerHttpAuthHeaderParser authHeaderParser) { var importSpecsFactory = new MCPImportedActionMcpSpecsFactory(jobManager, () -> sessionDescriptorResolver.getOrCreateFunctionFrame(FcliExecutionContextHolder.getMcpRequestAuthScopeKey())); var toolSpecs = new ArrayList(); var resourceTemplateSpecs = new ArrayList(); - for ( var importPath : config.getResolvedImportPaths() ) { + for (var importPath : config.getResolvedImportPaths()) { var importedSpecs = importSpecsFactory.create(importPath); importedSpecs.tools().forEach(tool -> toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() .tool(tool.tool()) @@ -117,30 +138,39 @@ public Integer call() throws Exception { .callHandler((ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, authHeaderParser, () -> jobToolSpec.callHandler().apply(null, request))) .build()); - - if ( toolSpecs.size() == 1 ) { + if (toolSpecs.size() == 1) { throw new FcliSimpleException("HTTP MCP config imports did not produce any exported functions"); } + return new McpSpecs(toolSpecs, resourceTemplateSpecs); + } + private JdkHttpServerMcpStatelessTransport createTransport(MCPServerHttpConfig config) { var objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - var transport = new JdkHttpServerMcpStatelessTransport(config.getServer(), "/mcp", new JacksonMcpJsonMapper(objectMapper)); + return new JdkHttpServerMcpStatelessTransport(config.getServer(), "/mcp", new JacksonMcpJsonMapper(objectMapper)); + } + private void buildAndStartServer(MCPServerHttpConfig config, + JdkHttpServerMcpStatelessTransport transport, McpSpecs specs) { var serverBuilder = McpServer.sync(transport) .serverInfo("fcli", FcliBuildProperties.INSTANCE.getFcliVersion()) .requestTimeout(Duration.ofSeconds(120)) .instructions("HTTP MCP server exposing imported fcli action functions") - .capabilities(getServerCapabilities(!resourceTemplateSpecs.isEmpty())) - .tools(toolSpecs); - if ( !resourceTemplateSpecs.isEmpty() ) { - serverBuilder.resourceTemplates(resourceTemplateSpecs); + .capabilities(getServerCapabilities(!specs.resourceTemplates().isEmpty())) + .tools(specs.tools()); + if (!specs.resourceTemplates().isEmpty()) { + serverBuilder.resourceTemplates(specs.resourceTemplates()); } var mcpServer = serverBuilder.build(); log.debug("Initialized HTTP MCP server instance: {}", mcpServer); - transport.start(); log.info("Fcli HTTP MCP server running on port {} for product {}", config.getServer().getPort(), config.getProduct()); System.err.println("Fcli HTTP MCP server running on port " + config.getServer().getPort() + " endpoint /mcp. Hit Ctrl-C to exit."); + } + private void awaitShutdown(JdkHttpServerMcpStatelessTransport transport, + AsyncJobManager asyncJobManager, + ScheduledExecutorService scopeCleanupScheduler, + MCPServerHttpSessionDescriptorResolver sessionDescriptorResolver) throws InterruptedException { var latch = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { transport.close(); @@ -150,7 +180,6 @@ public Integer call() throws Exception { latch.countDown(); }, "mcp-http-shutdown-hook")); latch.await(); - return 0; } private T withRequestExecutionContext(McpTransportContext transportContext, diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java index 37da881d122..955e61453a6 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -94,55 +94,56 @@ public Mono closeGracefully() { } private void handleExchange(HttpExchange exchange) throws IOException { - if ( closing ) { + if (!validateRequest(exchange)) { return; } + var transportContext = buildTransportContext(exchange); + dispatchMessage(exchange, transportContext); + } + + private boolean validateRequest(HttpExchange exchange) throws IOException { + if (closing) { sendPlainError(exchange, 503, "Server is shutting down"); - return; + return false; } - if ( !exchange.getRequestURI().getPath().equals(mcpEndpoint) ) { + if (!exchange.getRequestURI().getPath().equals(mcpEndpoint)) { sendPlainError(exchange, 404, "Not found"); - return; + return false; } - if ( !"POST".equalsIgnoreCase(exchange.getRequestMethod()) ) { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { sendPlainError(exchange, 405, "Method not allowed"); - return; + return false; } - if ( mcpHandler == null ) { + if (mcpHandler == null) { sendPlainError(exchange, 503, "MCP handler not initialized"); - return; + return false; } - var accept = getFirstHeader(exchange, "Accept"); - if ( accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM)) ) { + if (accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM))) { sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) .message("Both application/json and text/event-stream required in Accept header") .build()); - return; + return false; } + return true; + } - var transportContext = McpTransportContext.create(Map.of( + private McpTransportContext buildTransportContext(HttpExchange exchange) { + return McpTransportContext.create(Map.of( "method", exchange.getRequestMethod(), "path", exchange.getRequestURI().getPath(), "headers", exchange.getRequestHeaders().entrySet().stream() .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, e -> List.copyOf(e.getValue()))))); + } + + private void dispatchMessage(HttpExchange exchange, McpTransportContext transportContext) throws IOException { try { var bodyBytes = readRequestBody(exchange); - if ( bodyBytes == null ) { return; } // response already sent (body too large) + if (bodyBytes == null) { return; } var body = new String(bodyBytes, StandardCharsets.UTF_8); var message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); - if ( message instanceof McpSchema.JSONRPCRequest request ) { - var response = mcpHandler.handleRequest(transportContext, request) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - sendJson(exchange, 200, response); - } else if ( message instanceof McpSchema.JSONRPCNotification notification ) { - if ( INITIALIZED_NOTIFICATION_METHOD.equals(notification.method()) ) { - log.debug("Ignoring MCP initialized notification"); - } else { - mcpHandler.handleNotification(transportContext, notification) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - } - sendEmpty(exchange, 202); + if (message instanceof McpSchema.JSONRPCRequest request) { + handleJsonRpcRequest(exchange, transportContext, request); + } else if (message instanceof McpSchema.JSONRPCNotification notification) { + handleJsonRpcNotification(exchange, transportContext, notification); } else { sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) .message("The server accepts either requests or notifications") @@ -160,6 +161,26 @@ private void handleExchange(HttpExchange exchange) throws IOException { } } + private void handleJsonRpcRequest(HttpExchange exchange, McpTransportContext transportContext, + McpSchema.JSONRPCRequest request) throws IOException { + var response = mcpHandler.handleRequest(transportContext, request) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + sendJson(exchange, 200, response); + } + + private void handleJsonRpcNotification(HttpExchange exchange, McpTransportContext transportContext, + McpSchema.JSONRPCNotification notification) throws IOException { + if (INITIALIZED_NOTIFICATION_METHOD.equals(notification.method())) { + log.debug("Ignoring MCP initialized notification"); + } else { + mcpHandler.handleNotification(transportContext, notification) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + } + sendEmpty(exchange, 202); + } + private static final long DEFAULT_MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024; // 10 MB private byte[] readRequestBody(HttpExchange exchange) throws IOException { From 12ebad57b0768c64d1c94c9450958fd03d7dbafb Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 14:57:25 +0200 Subject: [PATCH 54/55] refactor: ai-assist helper classes for better separation of concerns --- ...AssistExtensionsListAssistantsCommand.java | 4 +- ...iAssistExtensionsListInstalledCommand.java | 4 +- ...AiAssistExtensionsListVersionsCommand.java | 4 +- .../cmd/AiAssistExtensionsSetupCommand.java | 4 +- .../AiAssistExtensionsUninstallCommand.java | 4 +- .../AiAssistExtensionsContentHelper.java | 206 ++++ .../helper/AiAssistExtensionsHelper.java | 311 ++++++ .../AiAssistExtensionsInstallPlanContext.java | 60 -- .../AiAssistExtensionsInstallPlanHelper.java | 287 +++++ .../helper/AiAssistExtensionsInstaller.java | 999 ------------------ .../helper/AiAssistExtensionsStateHelper.java | 271 +++++ .../extensions/helper/package-info.java | 49 + .../cli/cmd/AiAssistMCPStartHttpCommand.java | 5 +- 13 files changed, 1137 insertions(+), 1071 deletions(-) create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentHelper.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsHelper.java delete mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanContext.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanHelper.java delete mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateHelper.java create mode 100644 fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/package-info.java diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListAssistantsCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListAssistantsCommand.java index 642fc8bad80..698f49a64bb 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListAssistantsCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListAssistantsCommand.java @@ -13,7 +13,7 @@ package com.fortify.cli.ai_assist.extensions.cli.cmd; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsHelper; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -36,7 +36,7 @@ public class AiAssistExtensionsListAssistantsCommand extends AbstractOutputComma @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.listAssistants(detect)); + AiAssistExtensionsHelper.listAssistants(detect)); } @Override diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListInstalledCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListInstalledCommand.java index acaeb7de278..b8bc7e51d42 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListInstalledCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListInstalledCommand.java @@ -13,7 +13,7 @@ package com.fortify.cli.ai_assist.extensions.cli.cmd; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsHelper; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -31,7 +31,7 @@ public class AiAssistExtensionsListInstalledCommand extends AbstractOutputComman @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.listInstalled()); + AiAssistExtensionsHelper.listInstalled()); } @Override diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java index 58fcf15008a..bb204357161 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java @@ -13,7 +13,7 @@ package com.fortify.cli.ai_assist.extensions.cli.cmd; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsHelper; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -31,7 +31,7 @@ public class AiAssistExtensionsListVersionsCommand extends AbstractOutputCommand @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.listVersions()); + AiAssistExtensionsHelper.listVersions()); } @Override diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java index b8165c65140..c49c911e811 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java @@ -15,7 +15,7 @@ import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsHelper; import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.JsonHelper; @@ -67,7 +67,7 @@ public JsonNode getJsonNode() { throw new FcliSimpleException("--content-types is required when using --dir"); } return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.setup( + AiAssistExtensionsHelper.setup( source, version, targetSelection.assistants, targetSelection.autoDetect, contentTypeFilter, customDir, onDigestMismatch, dryRun)); } diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java index 8c934eabb1a..02f6b586a9e 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java @@ -15,7 +15,7 @@ import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstaller; +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsHelper; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -47,7 +47,7 @@ public class AiAssistExtensionsUninstallCommand extends AbstractOutputCommand @Override public JsonNode getJsonNode() { return JsonHelper.getObjectMapper().valueToTree( - AiAssistExtensionsInstaller.uninstall(contentTypeFilter, customDir, dryRun)); + AiAssistExtensionsHelper.uninstall(contentTypeFilter, customDir, dryRun)); } @Override diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentHelper.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentHelper.java new file mode 100644 index 00000000000..b26643984d4 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentHelper.java @@ -0,0 +1,206 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Discovers source files from extension content manifests and computes + * target-relative paths for installation. + */ +final class AiAssistExtensionsContentHelper { + private AiAssistExtensionsContentHelper() {} + + // ──────────────────────────── Content discovery ──────────────────────────── + + static List discoverSourceFiles( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsTargetDescriptor target, + AiAssistExtensionsSourceHandler sourceHandler) { + var contentType = target.getContentType(); + var ctDesc = contentManifest.getContentTypes() != null + ? contentManifest.getContentTypes().get(contentType) : null; + if (ctDesc == null) { return Collections.emptyList(); } + + var discoverMode = ctDesc.getDiscover(); + if ("explicit".equals(discoverMode)) { + return discoverExplicitEntries(ctDesc, target, sourceHandler); + } + + var sourceDir = ctDesc.getSourceDir(); + if (sourceDir == null || !sourceHandler.exists(sourceDir)) { + return Collections.emptyList(); + } + + if ("directory".equals(discoverMode)) { + return discoverDirectoryEntries(sourceDir, ctDesc.getEntryMarker(), sourceHandler); + } else if ("files".equals(discoverMode)) { + return discoverFileEntries(sourceDir, ctDesc.getFilePattern(), sourceHandler); + } + return Collections.emptyList(); + } + + static List discoverSourceFilesForContentType( + AiAssistExtensionsContentTypeDescriptor ctDesc, + AiAssistExtensionsSourceHandler sourceHandler) { + var discoverMode = ctDesc.getDiscover(); + + if ("explicit".equals(discoverMode)) { + return discoverAllExplicitEntries(ctDesc, sourceHandler); + } + + var sourceDir = ctDesc.getSourceDir(); + if (sourceDir == null || !sourceHandler.exists(sourceDir)) { + return Collections.emptyList(); + } + if ("directory".equals(discoverMode)) { + return discoverDirectoryEntries(sourceDir, ctDesc.getEntryMarker(), sourceHandler); + } else if ("files".equals(discoverMode)) { + return discoverFileEntries(sourceDir, ctDesc.getFilePattern(), sourceHandler); + } + return Collections.emptyList(); + } + + private static List discoverDirectoryEntries( + String sourceDir, String entryMarker, + AiAssistExtensionsSourceHandler sourceHandler) { + var result = new ArrayList(); + sourceHandler.listDirs(sourceDir).forEach(dir -> { + if (entryMarker != null) { + var markerPath = dir.resolve(entryMarker); + if (!sourceHandler.exists(markerPath.toString())) { return; } + } + sourceHandler.listFiles(dir.toString()).forEach(f -> { + var relative = sourceHandler.getExtractedDir().relativize( + sourceHandler.getExtractedDir().resolve(f)); + result.add(relative.toString()); + }); + }); + return result; + } + + private static List discoverFileEntries( + String sourceDir, String filePattern, + AiAssistExtensionsSourceHandler sourceHandler) { + var result = new ArrayList(); + var globPattern = filePattern != null ? filePattern : "*"; + var matcher = FileSystems.getDefault().getPathMatcher("glob:" + globPattern); + sourceHandler.listFiles(sourceDir).forEach(f -> { + if (matcher.matches(f.getFileName())) { + result.add(f.toString()); + } + }); + return result; + } + + private static List discoverExplicitEntries( + AiAssistExtensionsContentTypeDescriptor ctDesc, + AiAssistExtensionsTargetDescriptor target, + AiAssistExtensionsSourceHandler sourceHandler) { + if (target.getSourceEntries() == null) { return Collections.emptyList(); } + var entriesMap = ctDesc.getEntries(); + var result = new ArrayList(); + for (var entryName : target.getSourceEntries()) { + var entryPath = entriesMap != null ? entriesMap.get(entryName) : entryName; + if (entryPath == null) { entryPath = entryName; } + if (sourceHandler.exists(entryPath)) { + var resolvedPath = sourceHandler.getExtractedDir().resolve(entryPath); + if (Files.isDirectory(resolvedPath)) { + sourceHandler.listFiles(entryPath).forEach(f -> result.add(f.toString())); + } else { + result.add(entryPath); + } + } + } + return result; + } + + private static List discoverAllExplicitEntries( + AiAssistExtensionsContentTypeDescriptor ctDesc, + AiAssistExtensionsSourceHandler sourceHandler) { + var entriesMap = ctDesc.getEntries(); + if (entriesMap == null) { return Collections.emptyList(); } + var result = new ArrayList(); + for (var entryPath : entriesMap.values()) { + if (entryPath != null && sourceHandler.exists(entryPath)) { + var resolvedPath = sourceHandler.getExtractedDir().resolve(entryPath); + if (Files.isDirectory(resolvedPath)) { + sourceHandler.listFiles(entryPath).forEach(f -> result.add(f.toString())); + } else { + result.add(entryPath); + } + } + } + return result; + } + + // ──────────────────────────── Target-relative path computation ──────────────────────────── + + static String getTargetRelativePath( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsTargetDescriptor target, String sourceFile) { + var contentType = target.getContentType(); + var ctDesc = contentManifest.getContentTypes() != null + ? contentManifest.getContentTypes().get(contentType) : null; + + if (ctDesc == null) { return Path.of(sourceFile).getFileName().toString(); } + + var discoverMode = ctDesc.getDiscover(); + if ("explicit".equals(discoverMode)) { + var entriesMap = ctDesc.getEntries(); + if (entriesMap != null && target.getSourceEntries() != null) { + for (var entryName : target.getSourceEntries()) { + var entryPath = entriesMap.getOrDefault(entryName, entryName); + if (sourceFile.startsWith(entryPath + "/")) { + return sourceFile.substring(entryPath.length() + 1); + } else if (sourceFile.equals(entryPath)) { + return Path.of(sourceFile).getFileName().toString(); + } + } + } + return Path.of(sourceFile).getFileName().toString(); + } + + if (ctDesc.getSourceDir() != null && sourceFile.startsWith(ctDesc.getSourceDir() + "/")) { + return sourceFile.substring(ctDesc.getSourceDir().length() + 1); + } + return sourceFile; + } + + static String getTargetRelativePathForContentType( + AiAssistExtensionsContentTypeDescriptor ctDesc, String sourceFile) { + var discoverMode = ctDesc.getDiscover(); + if ("explicit".equals(discoverMode)) { + var entriesMap = ctDesc.getEntries(); + if (entriesMap != null) { + for (var entryPath : entriesMap.values()) { + if (entryPath != null && sourceFile.startsWith(entryPath + "/")) { + return sourceFile.substring(entryPath.length() + 1); + } else if (sourceFile.equals(entryPath)) { + return Path.of(sourceFile).getFileName().toString(); + } + } + } + return Path.of(sourceFile).getFileName().toString(); + } + if (ctDesc.getSourceDir() != null && sourceFile.startsWith(ctDesc.getSourceDir() + "/")) { + return sourceFile.substring(ctDesc.getSourceDir().length() + 1); + } + return sourceFile; + } +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsHelper.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsHelper.java new file mode 100644 index 00000000000..a5c6ba2eb99 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsHelper.java @@ -0,0 +1,311 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionRootDescriptor; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionVersionDescriptor; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionsHelper; + +/** + * Public API for AI assistant extensions operations: setup, uninstall, and + * listing. Orchestrates {@link AiAssistExtensionsInstallPlanHelper}, + * {@link AiAssistExtensionsStateHelper}, and {@link AiAssistExtensionsContentHelper}. + */ +public final class AiAssistExtensionsHelper { + private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsHelper.class); + + private AiAssistExtensionsHelper() {} + + // ──────────────────────────── Version resolution ──────────────────────────── + + public static ToolDefinitionRootDescriptor getToolDefinitions() { + return ToolDefinitionsHelper.getToolDefinitionRootDescriptor( + AiAssistExtensionsSourceHandler.TOOL_NAME); + } + + public static ToolDefinitionVersionDescriptor resolveVersion(String version) { + return getToolDefinitions().getVersionOrDefault(version); + } + + // ──────────────────────────── Setup ──────────────────────────── + + public static List setup( + String source, String version, + Set assistants, boolean autoDetect, + Set contentTypeFilter, String customDir, + DigestMismatchAction onDigestMismatch, boolean dryRun) { + + try (var sourceHandler = resolveSource(source, version, onDigestMismatch)) { + var contentManifest = sourceHandler.readContentManifest(); + + if (customDir != null) { + return setupCustomDir(contentManifest, sourceHandler, contentTypeFilter, + customDir, sourceHandler.getVersion(), dryRun); + } + + var distribution = AiAssistExtensionsSourceHandler + .readDistributionDescriptor(source == null); + var selectedAssistants = selectAssistants(distribution, assistants, autoDetect); + var planContext = new AiAssistExtensionsInstallPlanHelper.PlanContext(); + var plan = AiAssistExtensionsInstallPlanHelper.buildSetupPlan(contentManifest, + distribution, selectedAssistants, sourceHandler, planContext, + contentTypeFilter, sourceHandler.getVersion()); + + warnDuplicateContentDirs(distribution, selectedAssistants, contentTypeFilter); + + if (!dryRun) { + AiAssistExtensionsInstallPlanHelper.executePlan(plan, sourceHandler); + AiAssistExtensionsStateHelper.saveInstallationsState(selectedAssistants, plan); + } + return AiAssistExtensionsInstallPlanHelper.toOutputDescriptors(plan); + } + } + + private static List setupCustomDir( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsSourceHandler sourceHandler, + Set contentTypeFilter, String customDir, + String sourceVersion, boolean dryRun) { + var plan = AiAssistExtensionsInstallPlanHelper.buildCustomDirPlan(contentManifest, + sourceHandler, contentTypeFilter, customDir, sourceVersion); + if (!dryRun) { + AiAssistExtensionsInstallPlanHelper.executePlan(plan, sourceHandler); + } + return AiAssistExtensionsInstallPlanHelper.toOutputDescriptors(plan); + } + + // ──────────────────────────── Uninstall ──────────────────────────── + + public static List uninstall( + Set contentTypeFilter, String customDir, boolean dryRun) { + var targetDirs = customDir != null + ? Set.of(Path.of(customDir).toAbsolutePath().normalize()) + : AiAssistExtensionsStateHelper.collectAllKnownTargetDirs(); + var results = new ArrayList(); + + for (var dir : targetDirs) { + for (var manifest : AiAssistExtensionsStateHelper.readAllTargetDirManifests(dir)) { + if (!AiAssistExtensionsStateHelper.matchesContentTypeFilter( + manifest.getContentType(), contentTypeFilter)) { + continue; + } + + var files = manifest.getFiles() != null ? manifest.getFiles() : List.of(); + if (!dryRun) { + for (var file : files) { + AiAssistExtensionsStateHelper.deleteTargetFile( + AiAssistExtensionsStateHelper.safeResolve(dir, file)); + } + AiAssistExtensionsStateHelper.deleteManifestFile(dir, manifest.getContentType()); + } + results.add(AiAssistExtensionsOutputDescriptor.builder() + .contentType(manifest.getContentType()) + .targetDir(dir.toString()) + .fileCount(files.size()) + .sourceVersion(manifest.getVersion()) + .files(files.toArray(String[]::new)) + .filesString(String.join(", ", files)) + .actionResult("REMOVED") + .build()); + } + } + + if (!dryRun && customDir == null) { + AiAssistExtensionsStateHelper.clearInstallationsState(contentTypeFilter); + } + return results; + } + + // ──────────────────────────── List installed ──────────────────────────── + + public static List listInstalled() { + var installations = AiAssistExtensionsStateHelper.loadInstallationsState(); + var results = new ArrayList(); + + for (var entry : installations.getAssistants().entrySet()) { + var assistantId = entry.getKey(); + var installation = entry.getValue(); + for (var targetEntry : installation.getTargets().entrySet()) { + var contentType = targetEntry.getKey(); + var targetDir = Path.of(targetEntry.getValue()); + var manifest = AiAssistExtensionsStateHelper.readTargetDirManifest(targetDir, contentType); + var files = manifest != null && manifest.getFiles() != null + ? manifest.getFiles() : List.of(); + var version = manifest != null ? manifest.getVersion() : null; + results.add(AiAssistExtensionsOutputDescriptor.builder() + .assistant(installation.getDisplayName()) + .assistantId(assistantId) + .contentType(contentType) + .targetDir(targetDir.toString()) + .fileCount(files.size()) + .sourceVersion(version) + .files(files.toArray(String[]::new)) + .filesString(String.join(", ", files)) + .build()); + } + } + return results; + } + + // ──────────────────────────── List versions ──────────────────────────── + + public static List listVersions() { + var defs = getToolDefinitions(); + var result = new ArrayList(); + for (var v : defs.getVersions()) { + result.add(AiAssistExtensionsVersionOutputDescriptor.builder() + .version(v.getVersion()) + .aliases(v.getAliases() != null ? String.join(", ", v.getAliases()) : "") + .stable(v.isStable()) + .build()); + } + return result; + } + + // ──────────────────────────── List assistants ──────────────────────────── + + public static List listAssistants(boolean detect) { + var distribution = AiAssistExtensionsSourceHandler.readDistributionDescriptor(true); + if (distribution.getAssistants() == null) { return Collections.emptyList(); } + + var installations = AiAssistExtensionsStateHelper.loadInstallationsState(); + + var result = new ArrayList(); + for (var entry : distribution.getAssistants().entrySet()) { + var id = entry.getKey(); + var assistant = entry.getValue(); + var contentTypes = assistant.getTargets() != null + ? assistant.getTargets().stream() + .map(AiAssistExtensionsTargetDescriptor::getContentType) + .toArray(String[]::new) + : new String[0]; + + String detected = detect + ? String.valueOf(AiAssistExtensionsConditionEvaluator.evaluate(assistant.getIfCondition())) + : "N/A"; + + var assistantInstallation = installations.getAssistants().get(id); + var installed = assistantInstallation != null; + String installedVersion = null; + if (installed) { + installedVersion = assistantInstallation.getTargets().entrySet().stream() + .map(e -> AiAssistExtensionsStateHelper.readTargetDirManifest( + Path.of(e.getValue()), e.getKey())) + .filter(m -> m != null) + .map(AiAssistExtensionsTargetDirManifest::getVersion) + .findFirst().orElse(null); + } + + result.add(AiAssistExtensionsAssistantOutputDescriptor.builder() + .id(id) + .name(assistant.getDisplayName()) + .contentTypes(contentTypes) + .contentTypesString(String.join(", ", contentTypes)) + .detected(detected) + .installed(installed) + .installedVersion(installedVersion) + .build()); + } + return result; + } + + // ──────────────────────────── Source resolution ──────────────────────────── + + private static AiAssistExtensionsSourceHandler resolveSource( + String source, String version, DigestMismatchAction onDigestMismatch) { + if (source != null) { + return AiAssistExtensionsSourceHandler.fromLocalSource(source); + } + var versionDesc = resolveVersion(version); + return AiAssistExtensionsSourceHandler.fromToolDefinitions(versionDesc, onDigestMismatch); + } + + // ──────────────────────────── Assistant selection ──────────────────────────── + + private static Map selectAssistants( + AiAssistExtensionsDistributionDescriptor distribution, + Set explicitAssistants, boolean autoDetect) { + var result = new LinkedHashMap(); + if (distribution.getAssistants() == null) { return result; } + + if (explicitAssistants != null && !explicitAssistants.isEmpty()) { + for (var id : explicitAssistants) { + var assistant = distribution.getAssistants().get(id); + if (assistant == null) { + throw new FcliSimpleException( + "Unknown assistant: " + id + ". Available: " + + String.join(", ", distribution.getAssistants().keySet())); + } + result.put(id, assistant); + } + } else if (autoDetect) { + for (var entry : distribution.getAssistants().entrySet()) { + if (AiAssistExtensionsConditionEvaluator.evaluate(entry.getValue().getIfCondition())) { + result.put(entry.getKey(), entry.getValue()); + } + } + } + return result; + } + + // ──────────────────────────── Duplicate content warning ──────────────────────────── + + private static void warnDuplicateContentDirs( + AiAssistExtensionsDistributionDescriptor distribution, + Map selectedAssistants, + Set contentTypeFilter) { + if (distribution.getAssistants() == null) { return; } + + for (var entry : distribution.getAssistants().entrySet()) { + var assistantId = entry.getKey(); + var assistant = entry.getValue(); + if (assistant.getTargets() == null) { continue; } + + for (var target : assistant.getTargets()) { + var contentType = target.getContentType(); + if (!AiAssistExtensionsStateHelper.matchesContentTypeFilter( + contentType, contentTypeFilter)) { continue; } + + var resolvedDirs = AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); + var dirsWithManifest = resolvedDirs.stream() + .filter(dir -> AiAssistExtensionsStateHelper.readTargetDirManifest(dir, contentType) != null + || selectedAssistants.containsKey(assistantId)) + .filter(dir -> AiAssistExtensionsStateHelper.readTargetDirManifest(dir, contentType) != null) + .toList(); + + if (dirsWithManifest.size() > 1) { + LOG.warn("Content type '{}' exists in multiple directories accessible by {}: {}. " + + "This may cause duplicate entries in the assistant. Consider running " + + "'uninstall' to clean up before re-running 'setup'.", + contentType, assistant.getDisplayName(), + dirsWithManifest.stream().map(Path::toString) + .collect(Collectors.joining(", "))); + } + } + } + } +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanContext.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanContext.java deleted file mode 100644 index 2d9e82b25b3..00000000000 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanContext.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.ai_assist.extensions.helper; - -import java.nio.file.Path; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** - * Tracks the state of an install/update plan for directory-overlap deduplication. - * When multiple assistants share a target directory, only the first one installs; - * subsequent assistants reuse the existing files (EXISTING). - */ -public final class AiAssistExtensionsInstallPlanContext { - /** - * Set of resolved target directory + content type combinations that have - * already been covered (files installed or planned) by a previous assistant. - */ - private final Set coveredDirs = new HashSet<>(); - - /** - * Mark a target directory as covered (files have been installed there). - */ - public void markCovered(Path resolvedTargetDir, String contentType) { - coveredDirs.add(toCoverageKey(resolvedTargetDir, contentType)); - } - - /** - * Check if a target directory is already covered for a given content type. - */ - public boolean isCovered(Path resolvedTargetDir, String contentType) { - return coveredDirs.contains(toCoverageKey(resolvedTargetDir, contentType)); - } - - /** - * Find the first covered directory from a list of candidates. - * @return the first covered path, or null if none are covered - */ - public Path findCoveredDir(List candidates, String contentType) { - return candidates.stream() - .filter(p -> isCovered(p, contentType)) - .findFirst() - .orElse(null); - } - - private static Path toCoverageKey(Path dir, String contentType) { - return dir.resolve("__ct__" + contentType); - } -} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanHelper.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanHelper.java new file mode 100644 index 00000000000..4fa3b11135e --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstallPlanHelper.java @@ -0,0 +1,287 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.fortify.cli.common.exception.FcliSimpleException; + +/** + * Builds and executes idempotent install/update plans for AI assistant extensions. + * A plan describes which files to install, update, remove, or leave unchanged. + */ +final class AiAssistExtensionsInstallPlanHelper { + private AiAssistExtensionsInstallPlanHelper() {} + + record PlanEntry( + String assistant, String assistantId, String contentType, + String targetDir, String sourceFile, String targetRelPath, + String targetAbsPath, String sourceVersion, String action) {} + + /** + * Tracks directory-overlap deduplication during plan building. + * When multiple assistants share a target directory, only the first one + * installs; subsequent assistants reuse the existing files (EXISTING). + */ + static final class PlanContext { + private final Set coveredDirs = new HashSet<>(); + + void markCovered(Path resolvedTargetDir, String contentType) { + coveredDirs.add(toCoverageKey(resolvedTargetDir, contentType)); + } + + Path findCoveredDir(List candidates, String contentType) { + return candidates.stream() + .filter(p -> coveredDirs.contains(toCoverageKey(p, contentType))) + .findFirst() + .orElse(null); + } + + private static Path toCoverageKey(Path dir, String contentType) { + return dir.resolve("__ct__" + contentType); + } + } + + // ──────────────────────────── Setup plan (assistant-based) ──────────────────────────── + + static List buildSetupPlan( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsDistributionDescriptor distribution, + Map assistants, + AiAssistExtensionsSourceHandler sourceHandler, + PlanContext planContext, + Set contentTypeFilter, + String sourceVersion) { + var plan = new ArrayList(); + + for (var entry : assistants.entrySet()) { + var assistantId = entry.getKey(); + var assistant = entry.getValue(); + if (assistant.getTargets() == null) { continue; } + + for (var target : assistant.getTargets()) { + var contentType = target.getContentType(); + if (!AiAssistExtensionsStateHelper.matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } + + var resolvedDirs = AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); + if (resolvedDirs.isEmpty()) { continue; } + + var coveredDir = planContext.findCoveredDir(resolvedDirs, contentType); + boolean isExisting = coveredDir != null; + var resolvedDir = isExisting ? coveredDir : resolvedDirs.get(0); + + if (!isExisting) { + planContext.markCovered(resolvedDir, contentType); + } + + if (isExisting) { + addExistingEntries(plan, assistant, assistantId, contentType, + resolvedDir, contentManifest, target, sourceHandler, sourceVersion); + continue; + } + + addDiffEntries(plan, assistant.getDisplayName(), assistantId, contentType, + resolvedDir, contentManifest, target, sourceHandler, sourceVersion); + } + } + return plan; + } + + private static void addExistingEntries( + List plan, + AiAssistExtensionsAssistantDescriptor assistant, String assistantId, + String contentType, Path resolvedDir, + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsTargetDescriptor target, + AiAssistExtensionsSourceHandler sourceHandler, String sourceVersion) { + var sourceFiles = AiAssistExtensionsContentHelper.discoverSourceFiles(contentManifest, target, sourceHandler); + for (var sourceFile : sourceFiles) { + var targetRelPath = AiAssistExtensionsContentHelper.getTargetRelativePath(contentManifest, target, sourceFile); + plan.add(new PlanEntry( + assistant.getDisplayName(), assistantId, contentType, + resolvedDir.toString(), sourceFile, targetRelPath, + AiAssistExtensionsStateHelper.safeResolve(resolvedDir, targetRelPath).toString(), + sourceVersion, "EXISTING")); + } + } + + private static void addDiffEntries( + List plan, String displayName, String assistantId, + String contentType, Path resolvedDir, + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsTargetDescriptor target, + AiAssistExtensionsSourceHandler sourceHandler, String sourceVersion) { + var existingManifest = AiAssistExtensionsStateHelper.readTargetDirManifest(resolvedDir, contentType); + var existingFiles = existingManifest != null && existingManifest.getFiles() != null + ? new HashSet<>(existingManifest.getFiles()) : Set.of(); + + var sourceFiles = AiAssistExtensionsContentHelper.discoverSourceFiles(contentManifest, target, sourceHandler); + var handledRelPaths = new HashSet(); + for (var sourceFile : sourceFiles) { + var targetRelPath = AiAssistExtensionsContentHelper.getTargetRelativePath(contentManifest, target, sourceFile); + var targetAbsPath = AiAssistExtensionsStateHelper.safeResolve(resolvedDir, targetRelPath).toString(); + handledRelPaths.add(targetRelPath); + + String action; + if (!existingFiles.contains(targetRelPath)) { + action = "INSTALLED"; + } else if (AiAssistExtensionsStateHelper.hasFileChanged(sourceHandler, sourceFile, Path.of(targetAbsPath))) { + action = "UPDATED"; + } else { + action = "UNCHANGED"; + } + plan.add(new PlanEntry( + displayName, assistantId, contentType, + resolvedDir.toString(), sourceFile, targetRelPath, + targetAbsPath, sourceVersion, action)); + } + + for (var existingFile : existingFiles) { + if (!handledRelPaths.contains(existingFile)) { + plan.add(new PlanEntry( + displayName, assistantId, contentType, + resolvedDir.toString(), null, existingFile, + AiAssistExtensionsStateHelper.safeResolve(resolvedDir, existingFile).toString(), + sourceVersion, "REMOVED")); + } + } + } + + // ──────────────────────────── Custom-dir plan ──────────────────────────── + + static List buildCustomDirPlan( + AiAssistExtensionsContentManifestDescriptor contentManifest, + AiAssistExtensionsSourceHandler sourceHandler, + Set contentTypeFilter, String customDir, + String sourceVersion) { + var plan = new ArrayList(); + var resolvedDir = Path.of(customDir).toAbsolutePath().normalize(); + if (contentManifest.getContentTypes() == null) { return plan; } + + for (var ctEntry : contentManifest.getContentTypes().entrySet()) { + var contentType = ctEntry.getKey(); + if (!AiAssistExtensionsStateHelper.matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } + + var ctDesc = ctEntry.getValue(); + var existingManifest = AiAssistExtensionsStateHelper.readTargetDirManifest(resolvedDir, contentType); + var existingFiles = existingManifest != null && existingManifest.getFiles() != null + ? new HashSet<>(existingManifest.getFiles()) : Set.of(); + + var sourceFiles = AiAssistExtensionsContentHelper.discoverSourceFilesForContentType(ctDesc, sourceHandler); + var handledRelPaths = new HashSet(); + for (var sourceFile : sourceFiles) { + var targetRelPath = AiAssistExtensionsContentHelper.getTargetRelativePathForContentType(ctDesc, sourceFile); + var targetAbsPath = AiAssistExtensionsStateHelper.safeResolve(resolvedDir, targetRelPath).toString(); + handledRelPaths.add(targetRelPath); + + String action; + if (!existingFiles.contains(targetRelPath)) { + action = "INSTALLED"; + } else if (AiAssistExtensionsStateHelper.hasFileChanged(sourceHandler, sourceFile, Path.of(targetAbsPath))) { + action = "UPDATED"; + } else { + action = "UNCHANGED"; + } + plan.add(new PlanEntry( + null, null, contentType, + resolvedDir.toString(), sourceFile, targetRelPath, + targetAbsPath, sourceVersion, action)); + } + + for (var existingFile : existingFiles) { + if (!handledRelPaths.contains(existingFile)) { + plan.add(new PlanEntry( + null, null, contentType, + resolvedDir.toString(), null, existingFile, + AiAssistExtensionsStateHelper.safeResolve(resolvedDir, existingFile).toString(), + sourceVersion, "REMOVED")); + } + } + } + return plan; + } + + // ──────────────────────────── Plan execution ──────────────────────────── + + static void executePlan(List plan, + AiAssistExtensionsSourceHandler sourceHandler) { + var byDirAndType = plan.stream() + .filter(e -> !"EXISTING".equals(e.action())) + .collect(Collectors.groupingBy( + e -> e.targetDir() + "\0" + e.contentType(), + LinkedHashMap::new, Collectors.toList())); + + for (var dirEntries : byDirAndType.values()) { + for (var entry : dirEntries) { + switch (entry.action()) { + case "INSTALLED", "UPDATED" -> + AiAssistExtensionsStateHelper.installFile(sourceHandler, + entry.sourceFile(), Path.of(entry.targetAbsPath())); + case "REMOVED" -> + AiAssistExtensionsStateHelper.deleteTargetFile(Path.of(entry.targetAbsPath())); + } + } + + var first = dirEntries.get(0); + var installedFiles = dirEntries.stream() + .filter(e -> !"REMOVED".equals(e.action())) + .map(PlanEntry::targetRelPath) + .toList(); + AiAssistExtensionsStateHelper.writeTargetDirManifest(Path.of(first.targetDir()), + first.contentType(), first.sourceVersion(), installedFiles); + } + } + + // ──────────────────────────── Plan → output descriptors ──────────────────────────── + + static List toOutputDescriptors(List plan) { + var groups = plan.stream().collect(Collectors.groupingBy( + e -> e.assistantId() + "\0" + e.contentType() + "\0" + e.targetDir() + "\0" + e.action(), + LinkedHashMap::new, Collectors.toList())); + + var result = new ArrayList(); + for (var entries : groups.values()) { + var first = entries.get(0); + var files = entries.stream() + .map(PlanEntry::targetRelPath) + .toArray(String[]::new); + result.add(AiAssistExtensionsOutputDescriptor.builder() + .assistant(first.assistant()) + .assistantId(first.assistantId()) + .contentType(first.contentType()) + .targetDir(first.targetDir()) + .fileCount(files.length) + .sourceVersion(first.sourceVersion()) + .files(files) + .filesString(String.join(", ", files)) + .actionResult(first.action()) + .build()); + } + return result; + } + + // ──────────────────────────── Validation ──────────────────────────── + + static void validatePlanHasTools(List plan) { + if (plan.isEmpty()) { + throw new FcliSimpleException("No content to install for the selected assistants/content types"); + } + } +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java deleted file mode 100644 index 82747e335b4..00000000000 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsInstaller.java +++ /dev/null @@ -1,999 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.ai_assist.extensions.helper; - -import java.io.IOException; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstallationsDescriptor.AssistantInstallation; -import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsSourceHandler.DigestMismatchAction; -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.exception.FcliTechnicalException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.util.FcliDataHelper; -import com.fortify.cli.tool.definitions.helper.ToolDefinitionRootDescriptor; -import com.fortify.cli.tool.definitions.helper.ToolDefinitionVersionDescriptor; -import com.fortify.cli.tool.definitions.helper.ToolDefinitionsHelper; - -/** - * Core setup/uninstall/list logic for AI assistant extensions. - *

    - * State is managed in two tiers: - *

      - *
    • fcli state ({@code state/ai-assist/extensions/installations.json}): - * lightweight registry of which assistants were set up and their resolved - * target directories. Used by {@code list-installed} and {@code uninstall} - * to work without the distribution descriptor.
    • - *
    • Target-dir manifest ({@code .fortify-extensions..json} in each target dir): - * records content type, version, and file list. Enables diff-based updates - * and state recovery after fcli state reset.
    • - *
    - */ -public final class AiAssistExtensionsInstaller { - private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsInstaller.class); - private static final Path INSTALLATIONS_STATE_PATH = - Path.of("state", "ai-assist", "extensions", "installations.json"); - - private AiAssistExtensionsInstaller() {} - - // ──────────────────────────── Version resolution ──────────────────────────── - - public static ToolDefinitionRootDescriptor getToolDefinitions() { - return ToolDefinitionsHelper.getToolDefinitionRootDescriptor( - AiAssistExtensionsSourceHandler.TOOL_NAME); - } - - public static ToolDefinitionVersionDescriptor resolveVersion(String version) { - return getToolDefinitions().getVersionOrDefault(version); - } - - // ──────────────────────────── Setup (idempotent install/update) ──────────────────────────── - - /** - * Idempotent setup: installs if new, updates if already present - * (adds new files, updates changed files, removes obsolete files). - * - * @param source local zip/dir override, or null for tool-definitions - * @param version version string (default "latest") - * @param assistants explicit assistant IDs, or null - * @param autoDetect true to auto-detect assistants - * @param contentTypeFilter filter by content type, or null for all - * @param customDir custom target directory (mutually exclusive with assistants/autoDetect) - * @param onDigestMismatch action on signature mismatch - * @param dryRun if true, plan only without executing - */ - public static List setup( - String source, String version, - Set assistants, boolean autoDetect, - Set contentTypeFilter, String customDir, - DigestMismatchAction onDigestMismatch, boolean dryRun) { - - try (var sourceHandler = resolveSource(source, version, onDigestMismatch)) { - var contentManifest = sourceHandler.readContentManifest(); - - if (customDir != null) { - // --dir mode: install content types directly to custom directory, - // bypassing assistant selection entirely - var plan = buildCustomDirPlan(contentManifest, sourceHandler, - contentTypeFilter, customDir, sourceHandler.getVersion()); - if (!dryRun) { - executeSetupPlan(plan, sourceHandler); - } - return toOutputDescriptors(plan); - } - - var distribution = AiAssistExtensionsSourceHandler - .readDistributionDescriptor(source == null); - var selectedAssistants = selectAssistants(distribution, assistants, autoDetect); - var planContext = new AiAssistExtensionsInstallPlanContext(); - var plan = buildSetupPlan(contentManifest, distribution, selectedAssistants, - sourceHandler, planContext, contentTypeFilter, - sourceHandler.getVersion()); - - warnDuplicateContentDirs(distribution, selectedAssistants, contentTypeFilter); - - if (!dryRun) { - executeSetupPlan(plan, sourceHandler); - saveInstallationsState(selectedAssistants, distribution, plan); - } - return toOutputDescriptors(plan); - } - } - - // ──────────────────────────── Uninstall ──────────────────────────── - - /** - * Uninstall extensions from target directories. When {@code customDir} is - * specified, only that directory is scanned. Otherwise, scans the union of - * dirs from the distribution descriptor and fcli state. - * - * @param contentTypeFilter optional content type filter, or null for all - * @param customDir specific directory to uninstall from, or null for all known dirs - * @param dryRun if true, report only without deleting - */ - public static List uninstall( - Set contentTypeFilter, String customDir, boolean dryRun) { - var targetDirs = customDir != null - ? Set.of(Path.of(customDir).toAbsolutePath().normalize()) - : collectAllKnownTargetDirs(); - var results = new ArrayList(); - - for (var dir : targetDirs) { - for (var manifest : readAllTargetDirManifests(dir)) { - if (!matchesContentTypeFilter(manifest.getContentType(), contentTypeFilter)) { - continue; - } - - var files = manifest.getFiles() != null ? manifest.getFiles() : List.of(); - if (!dryRun) { - for (var file : files) { - deleteTargetFile(safeResolve(dir, file)); - } - deleteManifestFile(dir, manifest.getContentType()); - } - results.add(AiAssistExtensionsOutputDescriptor.builder() - .contentType(manifest.getContentType()) - .targetDir(dir.toString()) - .fileCount(files.size()) - .sourceVersion(manifest.getVersion()) - .files(files.toArray(String[]::new)) - .filesString(String.join(", ", files)) - .actionResult("REMOVED") - .build()); - } - } - - if (!dryRun && customDir == null) { - clearInstallationsState(contentTypeFilter); - } - return results; - } - - // ──────────────────────────── List installed ──────────────────────────── - - public static List listInstalled() { - var installations = loadInstallationsState(); - var results = new ArrayList(); - - for (var entry : installations.getAssistants().entrySet()) { - var assistantId = entry.getKey(); - var installation = entry.getValue(); - for (var targetEntry : installation.getTargets().entrySet()) { - var contentType = targetEntry.getKey(); - var targetDir = Path.of(targetEntry.getValue()); - var manifest = readTargetDirManifest(targetDir, contentType); - var files = manifest != null && manifest.getFiles() != null - ? manifest.getFiles() : List.of(); - var version = manifest != null ? manifest.getVersion() : null; - results.add(AiAssistExtensionsOutputDescriptor.builder() - .assistant(installation.getDisplayName()) - .assistantId(assistantId) - .contentType(contentType) - .targetDir(targetDir.toString()) - .fileCount(files.size()) - .sourceVersion(version) - .files(files.toArray(String[]::new)) - .filesString(String.join(", ", files)) - .build()); - } - } - return results; - } - - // ──────────────────────────── List versions ──────────────────────────── - - public static List listVersions() { - var defs = getToolDefinitions(); - var result = new ArrayList(); - for (var v : defs.getVersions()) { - result.add(AiAssistExtensionsVersionOutputDescriptor.builder() - .version(v.getVersion()) - .aliases(v.getAliases() != null ? String.join(", ", v.getAliases()) : "") - .stable(v.isStable()) - .build()); - } - return result; - } - - // ──────────────────────────── List assistants ──────────────────────────── - - public static List listAssistants(boolean detect) { - var distribution = AiAssistExtensionsSourceHandler.readDistributionDescriptor(true); - if (distribution.getAssistants() == null) { return Collections.emptyList(); } - - var installations = loadInstallationsState(); - - var result = new ArrayList(); - for (var entry : distribution.getAssistants().entrySet()) { - var id = entry.getKey(); - var assistant = entry.getValue(); - var contentTypes = assistant.getTargets() != null - ? assistant.getTargets().stream() - .map(AiAssistExtensionsTargetDescriptor::getContentType) - .toArray(String[]::new) - : new String[0]; - - String detected = detect - ? String.valueOf(AiAssistExtensionsConditionEvaluator.evaluate(assistant.getIfCondition())) - : "N/A"; - - var assistantInstallation = installations.getAssistants().get(id); - var installed = assistantInstallation != null; - String installedVersion = null; - if (installed) { - // Read version from first target dir manifest - installedVersion = assistantInstallation.getTargets().entrySet().stream() - .map(e -> readTargetDirManifest(Path.of(e.getValue()), e.getKey())) - .filter(m -> m != null) - .map(AiAssistExtensionsTargetDirManifest::getVersion) - .findFirst().orElse(null); - } - - result.add(AiAssistExtensionsAssistantOutputDescriptor.builder() - .id(id) - .name(assistant.getDisplayName()) - .contentTypes(contentTypes) - .contentTypesString(String.join(", ", contentTypes)) - .detected(detected) - .installed(installed) - .installedVersion(installedVersion) - .build()); - } - return result; - } - - // ──────────────────────────── Source resolution ──────────────────────────── - - private static AiAssistExtensionsSourceHandler resolveSource( - String source, String version, DigestMismatchAction onDigestMismatch) { - if (source != null) { - return AiAssistExtensionsSourceHandler.fromLocalSource(source); - } - var versionDesc = resolveVersion(version); - return AiAssistExtensionsSourceHandler.fromToolDefinitions(versionDesc, onDigestMismatch); - } - - // ──────────────────────────── Assistant selection ──────────────────────────── - - private static Map selectAssistants( - AiAssistExtensionsDistributionDescriptor distribution, - Set explicitAssistants, boolean autoDetect) { - var result = new LinkedHashMap(); - if (distribution.getAssistants() == null) { return result; } - - if (explicitAssistants != null && !explicitAssistants.isEmpty()) { - for (var id : explicitAssistants) { - var assistant = distribution.getAssistants().get(id); - if (assistant == null) { - throw new FcliSimpleException( - "Unknown assistant: " + id + ". Available: " - + String.join(", ", distribution.getAssistants().keySet())); - } - result.put(id, assistant); - } - } else if (autoDetect) { - for (var entry : distribution.getAssistants().entrySet()) { - if (AiAssistExtensionsConditionEvaluator.evaluate(entry.getValue().getIfCondition())) { - result.put(entry.getKey(), entry.getValue()); - } - } - } - return result; - } - - // ──────────────────────────── Internal plan entry ──────────────────────────── - - private record PlanEntry( - String assistant, String assistantId, String contentType, - String targetDir, String sourceFile, String targetRelPath, - String targetAbsPath, String sourceVersion, String action) {} - - // ──────────────────────────── Setup plan ──────────────────────────── - - /** - * Build a setup plan that is idempotent: installs new files, updates changed - * files, removes obsolete files, and reports unchanged files. - */ - private static List buildSetupPlan( - AiAssistExtensionsContentManifestDescriptor contentManifest, - AiAssistExtensionsDistributionDescriptor distribution, - Map assistants, - AiAssistExtensionsSourceHandler sourceHandler, - AiAssistExtensionsInstallPlanContext planContext, - Set contentTypeFilter, - String sourceVersion) { - var plan = new ArrayList(); - - for (var entry : assistants.entrySet()) { - var assistantId = entry.getKey(); - var assistant = entry.getValue(); - if (assistant.getTargets() == null) { continue; } - - for (var target : assistant.getTargets()) { - var contentType = target.getContentType(); - if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } - - var resolvedDirs = AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); - if (resolvedDirs.isEmpty()) { continue; } - - var coveredDir = planContext.findCoveredDir(resolvedDirs, contentType); - boolean isExisting = coveredDir != null; - var resolvedDir = isExisting ? coveredDir : resolvedDirs.get(0); - - if (!isExisting) { - planContext.markCovered(resolvedDir, contentType); - } - - if (isExisting) { - // Another assistant already handles this dir in this run - addExistingEntries(plan, assistant, assistantId, contentType, - resolvedDir, contentManifest, target, sourceHandler, sourceVersion); - continue; - } - - // Read existing manifest from target dir for diff - var existingManifest = readTargetDirManifest(resolvedDir, contentType); - var existingFiles = existingManifest != null && existingManifest.getFiles() != null - ? new HashSet<>(existingManifest.getFiles()) : Set.of(); - - var sourceFiles = discoverSourceFiles(contentManifest, target, sourceHandler); - var handledRelPaths = new HashSet(); - for (var sourceFile : sourceFiles) { - var targetRelPath = getTargetRelativePath(contentManifest, target, sourceFile); - var targetAbsPath = safeResolve(resolvedDir, targetRelPath).toString(); - handledRelPaths.add(targetRelPath); - - String action; - if (!existingFiles.contains(targetRelPath)) { - action = "INSTALLED"; - } else if (hasFileChanged(sourceHandler, sourceFile, Path.of(targetAbsPath))) { - action = "UPDATED"; - } else { - action = "UNCHANGED"; - } - plan.add(new PlanEntry( - assistant.getDisplayName(), assistantId, contentType, - resolvedDir.toString(), sourceFile, targetRelPath, - targetAbsPath, sourceVersion, action)); - } - - // Files in existing manifest but not in source → REMOVED - for (var existingFile : existingFiles) { - if (!handledRelPaths.contains(existingFile)) { - plan.add(new PlanEntry( - assistant.getDisplayName(), assistantId, contentType, - resolvedDir.toString(), null, existingFile, - safeResolve(resolvedDir, existingFile).toString(), - sourceVersion, "REMOVED")); - } - } - } - } - return plan; - } - - private static void addExistingEntries( - List plan, - AiAssistExtensionsAssistantDescriptor assistant, String assistantId, - String contentType, Path resolvedDir, - AiAssistExtensionsContentManifestDescriptor contentManifest, - AiAssistExtensionsTargetDescriptor target, - AiAssistExtensionsSourceHandler sourceHandler, String sourceVersion) { - var sourceFiles = discoverSourceFiles(contentManifest, target, sourceHandler); - for (var sourceFile : sourceFiles) { - var targetRelPath = getTargetRelativePath(contentManifest, target, sourceFile); - plan.add(new PlanEntry( - assistant.getDisplayName(), assistantId, contentType, - resolvedDir.toString(), sourceFile, targetRelPath, - safeResolve(resolvedDir, targetRelPath).toString(), - sourceVersion, "EXISTING")); - } - } - - // ──────────────────────────── Custom-dir plan ──────────────────────────── - - /** - * Build a setup plan for --dir mode: installs content types directly to - * a custom directory, bypassing assistant selection. Content is discovered - * from the content manifest without relying on assistant-specific config. - */ - private static List buildCustomDirPlan( - AiAssistExtensionsContentManifestDescriptor contentManifest, - AiAssistExtensionsSourceHandler sourceHandler, - Set contentTypeFilter, String customDir, - String sourceVersion) { - var plan = new ArrayList(); - var resolvedDir = Path.of(customDir).toAbsolutePath().normalize(); - if (contentManifest.getContentTypes() == null) { return plan; } - - for (var ctEntry : contentManifest.getContentTypes().entrySet()) { - var contentType = ctEntry.getKey(); - if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } - - var ctDesc = ctEntry.getValue(); - var existingManifest = readTargetDirManifest(resolvedDir, contentType); - var existingFiles = existingManifest != null && existingManifest.getFiles() != null - ? new HashSet<>(existingManifest.getFiles()) : Set.of(); - - var sourceFiles = discoverSourceFilesForContentType(ctDesc, sourceHandler); - var handledRelPaths = new HashSet(); - for (var sourceFile : sourceFiles) { - var targetRelPath = getTargetRelativePathForContentType(ctDesc, sourceFile); - var targetAbsPath = safeResolve(resolvedDir, targetRelPath).toString(); - handledRelPaths.add(targetRelPath); - - String action; - if (!existingFiles.contains(targetRelPath)) { - action = "INSTALLED"; - } else if (hasFileChanged(sourceHandler, sourceFile, Path.of(targetAbsPath))) { - action = "UPDATED"; - } else { - action = "UNCHANGED"; - } - plan.add(new PlanEntry( - null, null, contentType, - resolvedDir.toString(), sourceFile, targetRelPath, - targetAbsPath, sourceVersion, action)); - } - - for (var existingFile : existingFiles) { - if (!handledRelPaths.contains(existingFile)) { - plan.add(new PlanEntry( - null, null, contentType, - resolvedDir.toString(), null, existingFile, - safeResolve(resolvedDir, existingFile).toString(), - sourceVersion, "REMOVED")); - } - } - } - return plan; - } - - // ──────────────────────────── Plan → grouped output ──────────────────────────── - - private static List toOutputDescriptors(List plan) { - var groups = plan.stream().collect(Collectors.groupingBy( - e -> e.assistantId() + "\0" + e.contentType() + "\0" + e.targetDir() + "\0" + e.action(), - LinkedHashMap::new, Collectors.toList())); - - var result = new ArrayList(); - for (var entries : groups.values()) { - var first = entries.get(0); - var files = entries.stream() - .map(PlanEntry::targetRelPath) - .toArray(String[]::new); - result.add(AiAssistExtensionsOutputDescriptor.builder() - .assistant(first.assistant()) - .assistantId(first.assistantId()) - .contentType(first.contentType()) - .targetDir(first.targetDir()) - .fileCount(files.length) - .sourceVersion(first.sourceVersion()) - .files(files) - .filesString(String.join(", ", files)) - .actionResult(first.action()) - .build()); - } - return result; - } - - // ──────────────────────────── Content discovery ──────────────────────────── - - private static List discoverSourceFiles( - AiAssistExtensionsContentManifestDescriptor contentManifest, - AiAssistExtensionsTargetDescriptor target, - AiAssistExtensionsSourceHandler sourceHandler) { - var contentType = target.getContentType(); - var ctDesc = contentManifest.getContentTypes() != null - ? contentManifest.getContentTypes().get(contentType) : null; - if (ctDesc == null) { return Collections.emptyList(); } - - var discoverMode = ctDesc.getDiscover(); - if ("explicit".equals(discoverMode)) { - return discoverExplicitEntries(ctDesc, target, sourceHandler); - } - - var sourceDir = ctDesc.getSourceDir(); - if (sourceDir == null || !sourceHandler.exists(sourceDir)) { - return Collections.emptyList(); - } - - if ("directory".equals(discoverMode)) { - return discoverDirectoryEntries(sourceDir, ctDesc.getEntryMarker(), sourceHandler); - } else if ("files".equals(discoverMode)) { - return discoverFileEntries(sourceDir, ctDesc.getFilePattern(), sourceHandler); - } - return Collections.emptyList(); - } - - private static List discoverDirectoryEntries( - String sourceDir, String entryMarker, - AiAssistExtensionsSourceHandler sourceHandler) { - var result = new ArrayList(); - sourceHandler.listDirs(sourceDir).forEach(dir -> { - if (entryMarker != null) { - var markerPath = dir.resolve(entryMarker); - if (!sourceHandler.exists(markerPath.toString())) { return; } - } - sourceHandler.listFiles(dir.toString()).forEach(f -> { - var relative = sourceHandler.getExtractedDir().relativize( - sourceHandler.getExtractedDir().resolve(f)); - result.add(relative.toString()); - }); - }); - return result; - } - - private static List discoverFileEntries( - String sourceDir, String filePattern, - AiAssistExtensionsSourceHandler sourceHandler) { - var result = new ArrayList(); - var globPattern = filePattern != null ? filePattern : "*"; - sourceHandler.listFiles(sourceDir).forEach(f -> { - if (matchesGlob(f.getFileName().toString(), globPattern)) { - result.add(f.toString()); - } - }); - return result; - } - - private static List discoverExplicitEntries( - AiAssistExtensionsContentTypeDescriptor ctDesc, - AiAssistExtensionsTargetDescriptor target, - AiAssistExtensionsSourceHandler sourceHandler) { - if (target.getSourceEntries() == null) { return Collections.emptyList(); } - var entriesMap = ctDesc.getEntries(); - var result = new ArrayList(); - for (var entryName : target.getSourceEntries()) { - var entryPath = entriesMap != null ? entriesMap.get(entryName) : entryName; - if (entryPath == null) { entryPath = entryName; } - if (sourceHandler.exists(entryPath)) { - var resolvedPath = sourceHandler.getExtractedDir().resolve(entryPath); - if (Files.isDirectory(resolvedPath)) { - sourceHandler.listFiles(entryPath).forEach(f -> result.add(f.toString())); - } else { - result.add(entryPath); - } - } - } - return result; - } - - private static String getTargetRelativePath( - AiAssistExtensionsContentManifestDescriptor contentManifest, - AiAssistExtensionsTargetDescriptor target, String sourceFile) { - var contentType = target.getContentType(); - var ctDesc = contentManifest.getContentTypes() != null - ? contentManifest.getContentTypes().get(contentType) : null; - - if (ctDesc == null) { return Path.of(sourceFile).getFileName().toString(); } - - var discoverMode = ctDesc.getDiscover(); - if ("explicit".equals(discoverMode)) { - var entriesMap = ctDesc.getEntries(); - if (entriesMap != null && target.getSourceEntries() != null) { - for (var entryName : target.getSourceEntries()) { - var entryPath = entriesMap.getOrDefault(entryName, entryName); - if (sourceFile.startsWith(entryPath + "/")) { - return sourceFile.substring(entryPath.length() + 1); - } else if (sourceFile.equals(entryPath)) { - return Path.of(sourceFile).getFileName().toString(); - } - } - } - return Path.of(sourceFile).getFileName().toString(); - } - - if (ctDesc.getSourceDir() != null && sourceFile.startsWith(ctDesc.getSourceDir() + "/")) { - return sourceFile.substring(ctDesc.getSourceDir().length() + 1); - } - return sourceFile; - } - - // ──────────────────────────── Custom-dir content discovery ──────────────────────────── - - /** - * Discover source files for a content type without assistant-specific target config. - * For directory/files modes, behaves identically to the target-aware variant. - * For explicit mode, discovers all entries defined in the content type. - */ - private static List discoverSourceFilesForContentType( - AiAssistExtensionsContentTypeDescriptor ctDesc, - AiAssistExtensionsSourceHandler sourceHandler) { - var discoverMode = ctDesc.getDiscover(); - - if ("explicit".equals(discoverMode)) { - return discoverAllExplicitEntries(ctDesc, sourceHandler); - } - - var sourceDir = ctDesc.getSourceDir(); - if (sourceDir == null || !sourceHandler.exists(sourceDir)) { - return Collections.emptyList(); - } - if ("directory".equals(discoverMode)) { - return discoverDirectoryEntries(sourceDir, ctDesc.getEntryMarker(), sourceHandler); - } else if ("files".equals(discoverMode)) { - return discoverFileEntries(sourceDir, ctDesc.getFilePattern(), sourceHandler); - } - return Collections.emptyList(); - } - - /** - * Discover all explicit entries defined in the content type descriptor, - * without filtering by assistant-specific source-entries. - */ - private static List discoverAllExplicitEntries( - AiAssistExtensionsContentTypeDescriptor ctDesc, - AiAssistExtensionsSourceHandler sourceHandler) { - var entriesMap = ctDesc.getEntries(); - if (entriesMap == null) { return Collections.emptyList(); } - var result = new ArrayList(); - for (var entryPath : entriesMap.values()) { - if (entryPath != null && sourceHandler.exists(entryPath)) { - var resolvedPath = sourceHandler.getExtractedDir().resolve(entryPath); - if (Files.isDirectory(resolvedPath)) { - sourceHandler.listFiles(entryPath).forEach(f -> result.add(f.toString())); - } else { - result.add(entryPath); - } - } - } - return result; - } - - /** - * Compute target-relative path for a source file using only the content type - * descriptor (no assistant target). For explicit mode, strips entry path prefix. - */ - private static String getTargetRelativePathForContentType( - AiAssistExtensionsContentTypeDescriptor ctDesc, String sourceFile) { - var discoverMode = ctDesc.getDiscover(); - if ("explicit".equals(discoverMode)) { - var entriesMap = ctDesc.getEntries(); - if (entriesMap != null) { - for (var entryPath : entriesMap.values()) { - if (entryPath != null && sourceFile.startsWith(entryPath + "/")) { - return sourceFile.substring(entryPath.length() + 1); - } else if (sourceFile.equals(entryPath)) { - return Path.of(sourceFile).getFileName().toString(); - } - } - } - return Path.of(sourceFile).getFileName().toString(); - } - if (ctDesc.getSourceDir() != null && sourceFile.startsWith(ctDesc.getSourceDir() + "/")) { - return sourceFile.substring(ctDesc.getSourceDir().length() + 1); - } - return sourceFile; - } - - private static boolean matchesGlob(String filename, String glob) { - var matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob); - return matcher.matches(Path.of(filename)); - } - - // ──────────────────────────── Plan execution ──────────────────────────── - - private static void executeSetupPlan(List plan, - AiAssistExtensionsSourceHandler sourceHandler) { - // Group by (target dir, content type) to write one manifest per combo - var byDirAndType = plan.stream() - .filter(e -> !"EXISTING".equals(e.action())) - .collect(Collectors.groupingBy( - e -> e.targetDir() + "\0" + e.contentType(), - LinkedHashMap::new, Collectors.toList())); - - for (var dirEntries : byDirAndType.values()) { - for (var entry : dirEntries) { - switch (entry.action()) { - case "INSTALLED", "UPDATED" -> installFile(sourceHandler, entry); - case "REMOVED" -> deleteTargetFile(Path.of(entry.targetAbsPath())); - } - } - - // Write manifest for this (target dir, content type) pair - var first = dirEntries.get(0); - var installedFiles = dirEntries.stream() - .filter(e -> !"REMOVED".equals(e.action())) - .map(PlanEntry::targetRelPath) - .toList(); - writeTargetDirManifest(Path.of(first.targetDir()), first.contentType(), - first.sourceVersion(), installedFiles); - } - } - - private static void installFile(AiAssistExtensionsSourceHandler sourceHandler, PlanEntry entry) { - var targetPath = Path.of(entry.targetAbsPath()); - var sourceBytes = sourceHandler.readFileBytes(entry.sourceFile()); - if (sourceBytes == null) { - throw new FcliSimpleException("Source file not found: " + entry.sourceFile()); - } - try { - Files.createDirectories(targetPath.getParent()); - Files.write(targetPath, sourceBytes); - } catch (IOException e) { - throw new FcliTechnicalException("Error installing file: " + targetPath, e); - } - } - - // ──────────────────────────── Target dir manifest ──────────────────────────── - - private static void writeTargetDirManifest(Path targetDir, String contentType, - String version, List files) { - var manifest = AiAssistExtensionsTargetDirManifest.builder() - .schemaVersion(1) - .contentType(contentType) - .version(version) - .timestamp(Instant.now().toString()) - .files(files) - .build(); - var manifestPath = targetDir.resolve( - AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); - try { - Files.createDirectories(targetDir); - var json = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() - .writeValueAsString(manifest); - Files.writeString(manifestPath, json); - } catch (IOException e) { - throw new FcliTechnicalException("Error writing manifest: " + manifestPath, e); - } - } - - static AiAssistExtensionsTargetDirManifest readTargetDirManifest( - Path targetDir, String contentType) { - var manifestPath = targetDir.resolve( - AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); - if (!Files.isRegularFile(manifestPath)) { return null; } - return readManifestFile(manifestPath); - } - - static List readAllTargetDirManifests(Path targetDir) { - if (!Files.isDirectory(targetDir)) { return List.of(); } - var glob = AiAssistExtensionsTargetDirManifest.manifestGlob(); - var result = new ArrayList(); - try (var stream = Files.newDirectoryStream(targetDir, glob)) { - for (var path : stream) { - var manifest = readManifestFile(path); - if (manifest != null) { result.add(manifest); } - } - } catch (IOException e) { - LOG.warn("Error listing manifests in: {}", targetDir, e); - } - return result; - } - - private static AiAssistExtensionsTargetDirManifest readManifestFile(Path manifestPath) { - try { - var content = Files.readString(manifestPath); - return JsonHelper.getObjectMapper() - .readValue(content, AiAssistExtensionsTargetDirManifest.class); - } catch (IOException e) { - LOG.warn("Error reading manifest: {}", manifestPath, e); - return null; - } - } - - private static void deleteManifestFile(Path targetDir, String contentType) { - var manifestPath = targetDir.resolve( - AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); - try { - Files.deleteIfExists(manifestPath); - } catch (IOException e) { - LOG.warn("Error deleting manifest: {}", manifestPath, e); - } - } - - // ──────────────────────────── Installations state (fcli state) ──────────────────────────── - - private static AiAssistExtensionsInstallationsDescriptor loadInstallationsState() { - var desc = FcliDataHelper.readFile(INSTALLATIONS_STATE_PATH, - AiAssistExtensionsInstallationsDescriptor.class, false); - return desc != null ? desc : new AiAssistExtensionsInstallationsDescriptor(); - } - - private static void saveInstallationsState( - Map selectedAssistants, - AiAssistExtensionsDistributionDescriptor distribution, - List plan) { - var existing = loadInstallationsState(); - - // Build target dir map from plan for each assistant - var planTargets = plan.stream() - .filter(e -> !"EXISTING".equals(e.action()) && !"REMOVED".equals(e.action())) - .collect(Collectors.groupingBy(PlanEntry::assistantId, - LinkedHashMap::new, Collectors.toList())); - - for (var entry : selectedAssistants.entrySet()) { - var assistantId = entry.getKey(); - var assistant = entry.getValue(); - var entries = planTargets.getOrDefault(assistantId, List.of()); - - var targets = new LinkedHashMap(); - for (var planEntry : entries) { - targets.putIfAbsent(planEntry.contentType(), planEntry.targetDir()); - } - // Also include targets from EXISTING entries (shared dirs) - plan.stream() - .filter(e -> "EXISTING".equals(e.action()) && e.assistantId().equals(assistantId)) - .forEach(e -> targets.putIfAbsent(e.contentType(), e.targetDir())); - - if (!targets.isEmpty()) { - existing.getAssistants().put(assistantId, AssistantInstallation.builder() - .displayName(assistant.getDisplayName()) - .targets(targets) - .build()); - } - } - - FcliDataHelper.saveFile(INSTALLATIONS_STATE_PATH, existing, true); - } - - private static void clearInstallationsState(Set contentTypeFilter) { - if (contentTypeFilter == null || contentTypeFilter.isEmpty()) { - FcliDataHelper.deleteFile(INSTALLATIONS_STATE_PATH, false); - return; - } - // Partial clear: remove only matching content types from each assistant - var state = loadInstallationsState(); - var toRemove = new ArrayList(); - for (var entry : state.getAssistants().entrySet()) { - entry.getValue().getTargets().keySet().removeAll(contentTypeFilter); - if (entry.getValue().getTargets().isEmpty()) { - toRemove.add(entry.getKey()); - } - } - toRemove.forEach(state.getAssistants()::remove); - if (state.getAssistants().isEmpty()) { - FcliDataHelper.deleteFile(INSTALLATIONS_STATE_PATH, false); - } else { - FcliDataHelper.saveFile(INSTALLATIONS_STATE_PATH, state, true); - } - } - - // ──────────────────────────── Target dir collection ──────────────────────────── - - /** - * Collect all known target directories from both the distribution descriptor - * (if available) and the fcli installations state. - */ - private static Set collectAllKnownTargetDirs() { - var dirs = new LinkedHashSet(); - - // From distribution descriptor (if tool-definitions available) - try { - var distribution = AiAssistExtensionsSourceHandler.readDistributionDescriptor(true); - if (distribution.getAssistants() != null) { - for (var assistant : distribution.getAssistants().values()) { - if (assistant.getTargets() == null) { continue; } - for (var target : assistant.getTargets()) { - dirs.addAll(AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs())); - } - } - } - } catch (Exception e) { - LOG.debug("Distribution descriptor not available for uninstall scan", e); - } - - // From fcli state - var installations = loadInstallationsState(); - for (var installation : installations.getAssistants().values()) { - for (var dir : installation.getTargets().values()) { - dirs.add(Path.of(dir)); - } - } - - return dirs; - } - - // ──────────────────────────── Duplicate content warning ──────────────────────────── - - /** - * Warn when a content type is present in multiple directories that an - * assistant reads from, which may cause duplicate entries in that assistant. - */ - private static void warnDuplicateContentDirs( - AiAssistExtensionsDistributionDescriptor distribution, - Map selectedAssistants, - Set contentTypeFilter) { - if (distribution.getAssistants() == null) { return; } - - for (var entry : distribution.getAssistants().entrySet()) { - var assistantId = entry.getKey(); - var assistant = entry.getValue(); - if (assistant.getTargets() == null) { continue; } - - for (var target : assistant.getTargets()) { - var contentType = target.getContentType(); - if (!matchesContentTypeFilter(contentType, contentTypeFilter)) { continue; } - - var resolvedDirs = AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs()); - var dirsWithManifest = resolvedDirs.stream() - .filter(dir -> readTargetDirManifest(dir, contentType) != null - || selectedAssistants.containsKey(assistantId)) - .filter(dir -> readTargetDirManifest(dir, contentType) != null) - .toList(); - - if (dirsWithManifest.size() > 1) { - LOG.warn("Content type '{}' exists in multiple directories accessible by {}: {}. " - + "This may cause duplicate entries in the assistant. Consider running " - + "'uninstall' to clean up before re-running 'setup'.", - contentType, assistant.getDisplayName(), - dirsWithManifest.stream().map(Path::toString) - .collect(Collectors.joining(", "))); - } - } - } - } - - // ──────────────────────────── File operations ──────────────────────────── - - private static void deleteTargetFile(Path targetPath) { - try { - Files.deleteIfExists(targetPath); - var parent = targetPath.getParent(); - while (parent != null && Files.isDirectory(parent)) { - try (var stream = Files.list(parent)) { - if (stream.findAny().isEmpty()) { - Files.delete(parent); - parent = parent.getParent(); - } else { - break; - } - } - } - } catch (IOException e) { - LOG.warn("Error deleting file: {}", targetPath, e); - } - } - - private static boolean hasFileChanged( - AiAssistExtensionsSourceHandler sourceHandler, String sourceFile, Path targetPath) { - if (!Files.isRegularFile(targetPath)) { return true; } - try { - var sourceBytes = sourceHandler.readFileBytes(sourceFile); - var targetBytes = Files.readAllBytes(targetPath); - return sourceBytes == null || !Arrays.equals(sourceBytes, targetBytes); - } catch (IOException e) { - return true; - } - } - - private static boolean matchesContentTypeFilter(String contentType, Set filter) { - return filter == null || filter.isEmpty() || filter.contains(contentType); - } - - private static Path safeResolve(Path baseDir, String relativePath) { - var resolved = baseDir.resolve(relativePath).normalize(); - if (!resolved.startsWith(baseDir.normalize())) { - throw new FcliSimpleException( - "Path traversal detected: " + relativePath); - } - return resolved; - } -} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateHelper.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateHelper.java new file mode 100644 index 00000000000..5b45e9ceaa9 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsStateHelper.java @@ -0,0 +1,271 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.ai_assist.extensions.helper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.ai_assist.extensions.helper.AiAssistExtensionsInstallationsDescriptor.AssistantInstallation; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.FcliDataHelper; + +/** + * Manages persistent state for AI assistant extensions: + *
      + *
    • Target-dir manifests ({@code .fortify-extensions..json})
    • + *
    • Fcli installations state ({@code installations.json})
    • + *
    • File-level operations (install, delete, compare)
    • + *
    + */ +final class AiAssistExtensionsStateHelper { + private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsStateHelper.class); + private static final Path INSTALLATIONS_STATE_PATH = + Path.of("state", "ai-assist", "extensions", "installations.json"); + + private AiAssistExtensionsStateHelper() {} + + // ──────────────────────────── Target dir manifest I/O ──────────────────────────── + + static void writeTargetDirManifest(Path targetDir, String contentType, + String version, List files) { + var manifest = AiAssistExtensionsTargetDirManifest.builder() + .schemaVersion(1) + .contentType(contentType) + .version(version) + .timestamp(Instant.now().toString()) + .files(files) + .build(); + var manifestPath = targetDir.resolve( + AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); + try { + Files.createDirectories(targetDir); + var json = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsString(manifest); + Files.writeString(manifestPath, json); + } catch (IOException e) { + throw new FcliTechnicalException("Error writing manifest: " + manifestPath, e); + } + } + + static AiAssistExtensionsTargetDirManifest readTargetDirManifest( + Path targetDir, String contentType) { + var manifestPath = targetDir.resolve( + AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); + if (!Files.isRegularFile(manifestPath)) { return null; } + return readManifestFile(manifestPath); + } + + static List readAllTargetDirManifests(Path targetDir) { + if (!Files.isDirectory(targetDir)) { return List.of(); } + var glob = AiAssistExtensionsTargetDirManifest.manifestGlob(); + var result = new ArrayList(); + try (var stream = Files.newDirectoryStream(targetDir, glob)) { + for (var path : stream) { + var manifest = readManifestFile(path); + if (manifest != null) { result.add(manifest); } + } + } catch (IOException e) { + LOG.warn("Error listing manifests in: {}", targetDir, e); + } + return result; + } + + private static AiAssistExtensionsTargetDirManifest readManifestFile(Path manifestPath) { + try { + var content = Files.readString(manifestPath); + return JsonHelper.getObjectMapper() + .readValue(content, AiAssistExtensionsTargetDirManifest.class); + } catch (IOException e) { + LOG.warn("Error reading manifest: {}", manifestPath, e); + return null; + } + } + + static void deleteManifestFile(Path targetDir, String contentType) { + var manifestPath = targetDir.resolve( + AiAssistExtensionsTargetDirManifest.manifestFilename(contentType)); + try { + Files.deleteIfExists(manifestPath); + } catch (IOException e) { + LOG.warn("Error deleting manifest: {}", manifestPath, e); + } + } + + // ──────────────────────────── Installations state (fcli state) ──────────────────────────── + + static AiAssistExtensionsInstallationsDescriptor loadInstallationsState() { + var desc = FcliDataHelper.readFile(INSTALLATIONS_STATE_PATH, + AiAssistExtensionsInstallationsDescriptor.class, false); + return desc != null ? desc : new AiAssistExtensionsInstallationsDescriptor(); + } + + static void saveInstallationsState( + Map selectedAssistants, + List plan) { + var existing = loadInstallationsState(); + + var planTargets = plan.stream() + .filter(e -> !"EXISTING".equals(e.action()) && !"REMOVED".equals(e.action())) + .collect(Collectors.groupingBy( + AiAssistExtensionsInstallPlanHelper.PlanEntry::assistantId, + LinkedHashMap::new, Collectors.toList())); + + for (var entry : selectedAssistants.entrySet()) { + var assistantId = entry.getKey(); + var assistant = entry.getValue(); + var entries = planTargets.getOrDefault(assistantId, List.of()); + + var targets = new LinkedHashMap(); + for (var planEntry : entries) { + targets.putIfAbsent(planEntry.contentType(), planEntry.targetDir()); + } + plan.stream() + .filter(e -> "EXISTING".equals(e.action()) && e.assistantId().equals(assistantId)) + .forEach(e -> targets.putIfAbsent(e.contentType(), e.targetDir())); + + if (!targets.isEmpty()) { + existing.getAssistants().put(assistantId, AssistantInstallation.builder() + .displayName(assistant.getDisplayName()) + .targets(targets) + .build()); + } + } + + FcliDataHelper.saveFile(INSTALLATIONS_STATE_PATH, existing, true); + } + + static void clearInstallationsState(Set contentTypeFilter) { + if (contentTypeFilter == null || contentTypeFilter.isEmpty()) { + FcliDataHelper.deleteFile(INSTALLATIONS_STATE_PATH, false); + return; + } + var state = loadInstallationsState(); + var toRemove = new ArrayList(); + for (var entry : state.getAssistants().entrySet()) { + entry.getValue().getTargets().keySet().removeAll(contentTypeFilter); + if (entry.getValue().getTargets().isEmpty()) { + toRemove.add(entry.getKey()); + } + } + toRemove.forEach(state.getAssistants()::remove); + if (state.getAssistants().isEmpty()) { + FcliDataHelper.deleteFile(INSTALLATIONS_STATE_PATH, false); + } else { + FcliDataHelper.saveFile(INSTALLATIONS_STATE_PATH, state, true); + } + } + + // ──────────────────────────── Target dir collection ──────────────────────────── + + static Set collectAllKnownTargetDirs() { + var dirs = new LinkedHashSet(); + + try { + var distribution = AiAssistExtensionsSourceHandler.readDistributionDescriptor(true); + if (distribution.getAssistants() != null) { + for (var assistant : distribution.getAssistants().values()) { + if (assistant.getTargets() == null) { continue; } + for (var target : assistant.getTargets()) { + dirs.addAll(AiAssistExtensionsPathResolver.resolveAll(target.getTargetDirs())); + } + } + } + } catch (Exception e) { + LOG.debug("Distribution descriptor not available for uninstall scan", e); + } + + var installations = loadInstallationsState(); + for (var installation : installations.getAssistants().values()) { + for (var dir : installation.getTargets().values()) { + dirs.add(Path.of(dir)); + } + } + + return dirs; + } + + // ──────────────────────────── File operations ──────────────────────────── + + static void installFile(AiAssistExtensionsSourceHandler sourceHandler, + String sourceFile, Path targetPath) { + var sourceBytes = sourceHandler.readFileBytes(sourceFile); + if (sourceBytes == null) { + throw new FcliSimpleException("Source file not found: " + sourceFile); + } + try { + Files.createDirectories(targetPath.getParent()); + Files.write(targetPath, sourceBytes); + } catch (IOException e) { + throw new FcliTechnicalException("Error installing file: " + targetPath, e); + } + } + + static void deleteTargetFile(Path targetPath) { + try { + Files.deleteIfExists(targetPath); + var parent = targetPath.getParent(); + while (parent != null && Files.isDirectory(parent)) { + try (var stream = Files.list(parent)) { + if (stream.findAny().isEmpty()) { + Files.delete(parent); + parent = parent.getParent(); + } else { + break; + } + } + } + } catch (IOException e) { + LOG.warn("Error deleting file: {}", targetPath, e); + } + } + + static boolean hasFileChanged( + AiAssistExtensionsSourceHandler sourceHandler, String sourceFile, Path targetPath) { + if (!Files.isRegularFile(targetPath)) { return true; } + try { + var sourceBytes = sourceHandler.readFileBytes(sourceFile); + var targetBytes = Files.readAllBytes(targetPath); + return sourceBytes == null || !Arrays.equals(sourceBytes, targetBytes); + } catch (IOException e) { + return true; + } + } + + static Path safeResolve(Path baseDir, String relativePath) { + var resolved = baseDir.resolve(relativePath).normalize(); + if (!resolved.startsWith(baseDir.normalize())) { + throw new FcliSimpleException( + "Path traversal detected: " + relativePath); + } + return resolved; + } + + static boolean matchesContentTypeFilter(String contentType, Set filter) { + return filter == null || filter.isEmpty() || filter.contains(contentType); + } +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/package-info.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/package-info.java new file mode 100644 index 00000000000..8dea9065557 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/package-info.java @@ -0,0 +1,49 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +/** + * AI assistant extensions: installation, update, and management. + * + *

    Public API

    + *
      + *
    • {@link AiAssistExtensionsHelper} — Facade: setup, uninstall, list operations
    • + *
    + * + *

    Internal helpers (package-private)

    + *
      + *
    • {@link AiAssistExtensionsInstallPlanHelper} — Builds and executes diff-based install plans
    • + *
    • {@link AiAssistExtensionsContentHelper} — Discovers source files and computes target paths
    • + *
    • {@link AiAssistExtensionsStateHelper} — Persistent state: manifests, installations registry, file I/O
    • + *
    • {@link AiAssistExtensionsConditionEvaluator} — Evaluates platform/tool conditions for auto-detection
    • + *
    • {@link AiAssistExtensionsPathResolver} — Resolves target directory paths with platform/env expansion
    • + *
    • {@link AiAssistExtensionsSourceHandler} — Downloads, extracts, and reads extension archives
    • + *
    + * + *

    Descriptors

    + *
      + *
    • {@link AiAssistExtensionsDistributionDescriptor} — Distribution manifest (assistants + targets)
    • + *
    • {@link AiAssistExtensionsContentManifestDescriptor} — Content manifest (content types + discovery config)
    • + *
    • {@link AiAssistExtensionsContentTypeDescriptor} — Per-content-type discovery configuration
    • + *
    • {@link AiAssistExtensionsAssistantDescriptor} — Assistant definition (name, targets, conditions)
    • + *
    • {@link AiAssistExtensionsTargetDescriptor} — Target directory + content type binding
    • + *
    • {@link AiAssistExtensionsTargetDirManifest} — Per-directory installed-file manifest
    • + *
    • {@link AiAssistExtensionsInstallationsDescriptor} — Fcli-state registry of installed assistants
    • + *
    + * + *

    Output descriptors

    + *
      + *
    • {@link AiAssistExtensionsOutputDescriptor} — Setup/uninstall/list-installed output
    • + *
    • {@link AiAssistExtensionsVersionOutputDescriptor} — List-versions output
    • + *
    • {@link AiAssistExtensionsAssistantOutputDescriptor} — List-assistants output
    • + *
    + */ +package com.fortify.cli.ai_assist.extensions.helper; diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java index a1965faaf1b..61dc4356d27 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.ai_assist.mcp.cli.cmd; +import java.io.IOException; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; @@ -24,9 +25,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.ai_assist.mcp.helper.MCPImportedActionMcpSpecsFactory; import com.fortify.cli.ai_assist.mcp.helper.MCPJobManager; -import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfig; import com.fortify.cli.ai_assist.mcp.helper.http.JdkHttpServerMcpStatelessTransport; import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpAuthHeaderParser; +import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfig; import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpConfigLoader; import com.fortify.cli.ai_assist.mcp.helper.http.MCPServerHttpSessionDescriptorResolver; import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; @@ -144,7 +145,7 @@ private McpSpecs collectMcpSpecs(MCPServerHttpConfig config, MCPJobManager jobMa return new McpSpecs(toolSpecs, resourceTemplateSpecs); } - private JdkHttpServerMcpStatelessTransport createTransport(MCPServerHttpConfig config) { + private JdkHttpServerMcpStatelessTransport createTransport(MCPServerHttpConfig config) throws IOException { var objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return new JdkHttpServerMcpStatelessTransport(config.getServer(), "/mcp", new JacksonMcpJsonMapper(objectMapper)); } From 265f1da76702831ede780fd0a1a8ebed3d2070cd Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Sun, 24 May 2026 15:55:04 +0200 Subject: [PATCH 55/55] fix: fcli MCP/RPC servers: Fix background job race condition --- .../job/CachingJobEventListener.java | 90 ++++++++++++++----- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CachingJobEventListener.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CachingJobEventListener.java index de119e663f3..2a2432aa0a0 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CachingJobEventListener.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/concurrent/job/CachingJobEventListener.java @@ -13,10 +13,10 @@ package com.fortify.cli.common.concurrent.job; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -49,7 +49,7 @@ public void onJobStarted(String jobId, String description) { public void onRecord(String jobId, JsonNode record) { var cache = caches.get(jobId); if (cache != null) { - cache.records.add(record); + cache.addRecord(record); } } @@ -62,52 +62,51 @@ public void onProgress(String jobId, String message) { public void onJobComplete(String jobId, int exitCode, String stderr, String stdout) { var cache = caches.get(jobId); if (cache != null) { - cache.exitCode = exitCode; - cache.stderr = stderr; - cache.stdout = stdout; - cache.completed = true; - log.debug("Cache completed for job: {} records={} exitCode={}", jobId, cache.records.size(), exitCode); + cache.markComplete(exitCode, stderr, stdout); + log.debug("Cache completed for job: {} records={} exitCode={}", jobId, cache.recordCount(), exitCode); } } /** - * Return a page of cached records for the given job. + * Return a page of cached records for the given job. Takes a consistent + * snapshot of all mutable cache state under synchronization to avoid + * TOCTOU races between the writer thread and polling readers. */ public PageResult getPage(String jobId, int offset, int limit) { var cache = caches.get(jobId); if (cache == null) { return PageResult.notFound(jobId); } - var snapshot = List.copyOf(cache.records); - var totalLoaded = snapshot.size(); + var snap = cache.snapshot(); + var totalLoaded = snap.records().size(); var endIndex = Math.min(offset + limit, totalLoaded); - var pageRecords = offset >= totalLoaded ? List.of() : snapshot.subList(offset, endIndex); - var hasMore = (offset + limit < totalLoaded) || !cache.completed; + var pageRecords = offset >= totalLoaded ? List.of() : snap.records().subList(offset, endIndex); + var hasMore = (offset + limit < totalLoaded) || !snap.completed(); return PageResult.builder() .jobId(jobId) - .status(cache.completed ? (cache.exitCode == 0 ? "complete" : "error") : "loading") + .status(snap.completed() ? (snap.exitCode() == 0 ? "complete" : "error") : "loading") .records(pageRecords) .offset(offset) .limit(limit) .loadedCount(totalLoaded) .hasMore(hasMore) - .complete(cache.completed) - .exitCode(cache.exitCode) - .stderr(cache.stderr) - .stdout(cache.stdout) + .complete(snap.completed()) + .exitCode(snap.exitCode()) + .stderr(snap.stderr()) + .stdout(snap.stdout()) .build(); } /** Whether a cache exists and is complete for the given job. */ public boolean isComplete(String jobId) { var cache = caches.get(jobId); - return cache != null && cache.completed; + return cache != null && cache.isCompleted(); } /** Number of records loaded so far for the given job. */ public int getLoadedCount(String jobId) { var cache = caches.get(jobId); - return cache != null ? cache.records.size() : 0; + return cache != null ? cache.recordCount() : 0; } /** Whether a cache exists for the given job. */ @@ -161,17 +160,60 @@ private ScheduledExecutorService getEvictionScheduler() { } } + /** + * Per-job record cache with synchronized access to ensure consistent + * snapshots across records and completion state. + */ private static final class JobRecordCache { final String jobId; - final CopyOnWriteArrayList records = new CopyOnWriteArrayList<>(); - volatile boolean completed; - volatile int exitCode; - volatile String stderr; - volatile String stdout; + // All mutable state guarded by 'this' + private final List records = new ArrayList<>(); + private boolean completed; + private int exitCode; + private String stderr; + private String stdout; JobRecordCache(String jobId) { this.jobId = jobId; } + + synchronized void addRecord(JsonNode record) { + records.add(record); + } + + synchronized void markComplete(int exitCode, String stderr, String stdout) { + this.exitCode = exitCode; + this.stderr = stderr; + this.stdout = stdout; + this.completed = true; + } + + synchronized int recordCount() { + return records.size(); + } + + synchronized boolean isCompleted() { + return completed; + } + + /** Take a consistent snapshot of all mutable state. */ + synchronized CacheSnapshot snapshot() { + return new CacheSnapshot( + List.copyOf(records), + completed, + exitCode, + stderr, + stdout + ); + } + + record CacheSnapshot( + List records, + boolean completed, + int exitCode, + String stderr, + String stdout + ) {} } /** Result of a page query. */