diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9323f1c1248..3f3137672b3 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 @@ -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..e9afa3250c1 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,51 @@ 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 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 + +### `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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d5d1f90cc4..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: @@ -26,8 +27,13 @@ env: graal_java_version: 21 jobs: + check-duplicate-run: + uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main + create-release: name: create-release + 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 20348f4ad94..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: @@ -13,7 +14,12 @@ permissions: security-events: write jobs: + check-duplicate-run: + uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main + FoD-Scan: + 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' diff --git a/fcli-core/fcli-ai-assist/build.gradle.kts b/fcli-core/fcli-ai-assist/build.gradle.kts new file mode 100644 index 00000000000..ac285635eb6 --- /dev/null +++ b/fcli-core/fcli-ai-assist/build.gradle.kts @@ -0,0 +1,10 @@ +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-ai-assist/src/main/java/com/fortify/cli/ai_assist/_main/cli/cmd/AiAssistCommands.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/_main/cli/cmd/AiAssistCommands.java new file mode 100644 index 00000000000..ca2323a33a7 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/_main/cli/cmd/AiAssistCommands.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._main.cli.cmd; + +import static com.fortify.cli.common.cli.util.FcliModuleCategories.UTIL; + +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; + +import picocli.CommandLine.Command; + +@FcliModuleCategory(UTIL) +@Command( + name = "ai-assist", + aliases = {"ai"}, + resourceBundle = "com.fortify.cli.ai_assist.i18n.AiAssistMessages", + subcommands = { + AiAssistExtensionsCommands.class, + AiAssistMCPCommands.class + } +) +public class AiAssistCommands extends AbstractContainerCommand {} 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 new file mode 100644 index 00000000000..14da749d533 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsCommands.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.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +@Command( + name = "extensions", + aliases = {"ext"}, + subcommands = { + AiAssistExtensionsSetupCommand.class, + AiAssistExtensionsUninstallCommand.class, + AiAssistExtensionsListInstalledCommand.class, + AiAssistExtensionsListVersionsCommand.class, + AiAssistExtensionsListAssistantsCommand.class + } +) +public class AiAssistExtensionsCommands extends AbstractContainerCommand {} 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..698f49a64bb --- /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.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; +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( + AiAssistExtensionsHelper.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..b8bc7e51d42 --- /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.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; +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( + AiAssistExtensionsHelper.listInstalled()); + } + + @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/AiAssistExtensionsListVersionsCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.java new file mode 100644 index 00000000000..bb204357161 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsListVersionsCommand.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.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; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = "list-versions") +public class AiAssistExtensionsListVersionsCommand extends AbstractOutputCommand + implements IJsonNodeSupplier { + @Mixin @Getter private OutputHelperMixins.TableNoQuery outputHelper; + + @Override + public JsonNode getJsonNode() { + return JsonHelper.getObjectMapper().valueToTree( + AiAssistExtensionsHelper.listVersions()); + } + + @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/AiAssistExtensionsSetupCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java new file mode 100644 index 00000000000..c49c911e811 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsSetupCommand.java @@ -0,0 +1,94 @@ +/* + * 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.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; +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.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = OutputHelperMixins.Setup.CMD_NAME) +public class AiAssistExtensionsSetupCommand extends AbstractOutputCommand + implements IJsonNodeSupplier, IActionCommandResultSupplier { + @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", + defaultValue = "latest") + private String version; + + @Option(names = {"-s", "--source"}, paramLabel = "", + descriptionKey = "fcli.ai-assist.extensions.source") + private String source; + + @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 = {"--dry-run"}, + descriptionKey = "fcli.ai-assist.extensions.dry-run") + private boolean dryRun; + + @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( + AiAssistExtensionsHelper.setup( + source, version, targetSelection.assistants, targetSelection.autoDetect, + contentTypeFilter, customDir, onDigestMismatch, dryRun)); + } + + @Override + public boolean isSingular() { return false; } + + @Override + 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 new file mode 100644 index 00000000000..02f6b586a9e --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/cli/cmd/AiAssistExtensionsUninstallCommand.java @@ -0,0 +1,58 @@ +/* + * 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.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; +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 AiAssistExtensionsUninstallCommand extends AbstractOutputCommand + implements IJsonNodeSupplier, IActionCommandResultSupplier { + @Mixin @Getter private OutputHelperMixins.Uninstall outputHelper; + + @Option(names = {"--content-types"}, split = ",", paramLabel = "", + 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 = {"--dry-run"}, + descriptionKey = "fcli.ai-assist.extensions.dry-run") + private boolean dryRun; + + @Override + public JsonNode getJsonNode() { + return JsonHelper.getObjectMapper().valueToTree( + AiAssistExtensionsHelper.uninstall(contentTypeFilter, customDir, dryRun)); + } + + @Override + public boolean isSingular() { return false; } + + @Override + public String getActionCommandResult() { return "REMOVED"; } +} 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 new file mode 100644 index 00000000000..2d6c31c6221 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsAssistantDescriptor.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.ai_assist.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 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/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..369df40889b --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsConditionEvaluator.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.ai_assist.extensions.helper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +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 + * logical operators (any-of, all-of, not). + */ +public final class AiAssistExtensionsConditionEvaluator { + private static final Logger LOG = LoggerFactory.getLogger(AiAssistExtensionsConditionEvaluator.class); + + 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 static 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); + } + LOG.warn("WARN: Unknown condition type: {}", condition.getClass().getName()); + return false; + } + + @SuppressWarnings("unchecked") + private static 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 "any-of": + return evaluateAnyOf((List) value); + case "all-of": + return evaluateAllOf((List) value); + case "not": + return !evaluate(value); + default: + LOG.warn("WARN: Unknown condition type '{}', treating as false", key); + return false; + } + } + return true; + } + + private static 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 path. Useful for patterns like {@code ~/.vscode/extensions/github.copilot-*}. + * Value may be a plain string or a platform-specific map. + */ + private static boolean evaluateGlobExists(Object value) { + var pattern = resolvePlatformString(value); + if (pattern == null) { return false; } + if (pattern.startsWith("~/")) { + pattern = EnvHelper.getUserHome() + pattern.substring(1); + } + try { + return FileUtils.processGlobPathStream(pattern, p -> true, + stream -> stream.findAny().isPresent()); + } catch (Exception e) { + LOG.debug("Error evaluating glob '{}': {}", value, e.getMessage()); + return false; + } + } + + /** + * 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 static 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 = PlatformHelper.isWindows() + ? 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 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 static boolean evaluateAnyOf(List conditions) { + if (conditions == null) { return false; } + return conditions.stream().anyMatch(AiAssistExtensionsConditionEvaluator::evaluate); + } + + 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/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/AiAssistExtensionsContentManifestDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentManifestDescriptor.java new file mode 100644 index 00000000000..018c6f59f0b --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentManifestDescriptor.java @@ -0,0 +1,32 @@ +/* + * 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.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 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 AiAssistExtensionsContentManifestDescriptor { + private int schemaVersion; + @JsonProperty("content-types") + private Map contentTypes; +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentTypeDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentTypeDescriptor.java new file mode 100644 index 00000000000..1e9b109cedb --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsContentTypeDescriptor.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.ai_assist.extensions.helper; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Content type configuration from content-manifest.yaml. + * Defines how entries are discovered within the source archive. + */ +@Reflectable @NoArgsConstructor @Data +public class AiAssistExtensionsContentTypeDescriptor { + @JsonProperty("source-dir") + private String sourceDir; + private String discover; + @JsonProperty("entry-marker") + 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/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/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/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/AiAssistExtensionsOutputDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsOutputDescriptor.java new file mode 100644 index 00000000000..0218d6543cd --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsOutputDescriptor.java @@ -0,0 +1,42 @@ +/* + * 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.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/list-installed commands. + * One row per (assistant, contentType, targetDir) grouping. + */ +@Reflectable @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) @Data +public class AiAssistExtensionsOutputDescriptor { + private String assistant; + private String assistantId; + private String contentType; + private String targetDir; + 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-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 new file mode 100644 index 00000000000..a6b370d4294 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsPathResolver.java @@ -0,0 +1,105 @@ +/* + * 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.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.util.EnvHelper; +import com.fortify.cli.common.util.PlatformHelper; + +/** + * Resolves target-dir values from the descriptor: tilde expansion, + * ${VAR} environment variable substitution, and platform map selection. + */ +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 + * 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() { + return PlatformHelper.getOSString(); + } +} 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..44770505651 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsSourceHandler.java @@ -0,0 +1,275 @@ +/* + * 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.List; +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 = safeResolve(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(safeResolve(relativePath)); + } + + 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)) + .toList(); + } catch (IOException e) { + throw new FcliTechnicalException("Error listing files in: " + relativePath, e); + } + } + + 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) { + 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-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/AiAssistExtensionsTargetDescriptor.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDescriptor.java new file mode 100644 index 00000000000..83d807ed0f6 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDescriptor.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 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 AiAssistExtensionsTargetDescriptor { + @JsonProperty("content-type") + private String contentType; + @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/AiAssistExtensionsTargetDirManifest.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.java new file mode 100644 index 00000000000..5eeb9ceeca7 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/extensions/helper/AiAssistExtensionsTargetDirManifest.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.ai_assist.extensions.helper; + +import java.util.List; + +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; + +/** + * 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 { + 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; + @JsonProperty("content-type") + private String contentType; + private String version; + private String timestamp; + private List files; +} 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-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/AiAssistMCPCommands.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCommands.java new file mode 100644 index 00000000000..63352d0155f --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCommands.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.ai_assist.mcp.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +@Command( + name = "mcp", + subcommands = { + AiAssistMCPStartStdioCommand.class, + AiAssistMCPStartHttpCommand.class, + AiAssistMCPCreateHttpConfigCommand.class + } +) +public class AiAssistMCPCommands extends AbstractContainerCommand {} 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 new file mode 100644 index 00000000000..f43576cbd2d --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPCreateHttpConfigCommand.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.ai_assist.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 AiAssistMCPCreateHttpConfigCommand 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: " + outputPath, e); + } + 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/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 ) { + 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: " + templateResource, e); + } + } + + private enum HttpConfigType { + ssc, + fod + } +} 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 new file mode 100644 index 00000000000..61dc4356d27 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartHttpCommand.java @@ -0,0 +1,219 @@ +/* + * 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.mcp.cli.cmd; + +import java.io.IOException; +import java.nio.file.Path; +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; +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.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; +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; + +import io.modelcontextprotocol.common.McpTransportContext; +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 AiAssistMCPStartHttpCommand extends AbstractRunnableCommand implements IFcliExecutionContextManager { + private static final DateTimePeriodHelper PERIOD_HELPER = DateTimePeriodHelper.byRange(Period.MILLISECONDS, Period.MINUTES); + + @Option(names = {"--config", "-c"}, required = true) + private Path configPath; + + @Override + public Integer call() throws Exception { + 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; + } + + private void suppressProgressOutput() { + StdioHelper.setProgressOut(null); + StdioHelper.setProgressErr(null); + } + + 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); + } + + private ScheduledExecutorService scheduleScopeCleanup(MCPServerHttpConfig config, + MCPServerHttpSessionDescriptorResolver sessionDescriptorResolver) { + var scheduler = Executors.newSingleThreadScheduledExecutor( + r -> new Thread(r, "mcp-http-scope-cleanup")); + 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()) { + var importedSpecs = importSpecsFactory.create(importPath); + importedSpecs.tools().forEach(tool -> toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() + .tool(tool.tool()) + .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, authHeaderParser, + () -> resourceTemplate.readHandler().apply(ctx, request)) + ))); + } + var jobToolSpec = jobManager.getJobToolSpecification(); + toolSpecs.add(McpStatelessServerFeatures.SyncToolSpecification.builder() + .tool(jobToolSpec.tool()) + .callHandler((ctx, request) -> withRequestExecutionContext(ctx, sessionDescriptorResolver, authHeaderParser, + () -> jobToolSpec.callHandler().apply(null, request))) + .build()); + 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) throws IOException { + var objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + 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(!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(); + asyncJobManager.shutdown(); + scopeCleanupScheduler.shutdown(); + sessionDescriptorResolver.shutdown(); + latch.countDown(); + }, "mcp-http-shutdown-hook")); + latch.await(); + } + + private T withRequestExecutionContext(McpTransportContext transportContext, + MCPServerHttpSessionDescriptorResolver sessionDescriptorResolver, + MCPServerHttpAuthHeaderParser authHeaderParser, + Supplier supplier) + { + 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(); + } + } + } + + 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/cli/cmd/MCPServerStartCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java similarity index 86% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartCommand.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java index acd19e8f95c..bacebf6ba5d 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartCommand.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.util.mcp_server.cli.cmd; +package com.fortify.cli.ai_assist.mcp.cli.cmd; import java.io.FilterInputStream; import java.io.IOException; @@ -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; @@ -27,6 +28,19 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +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; @@ -36,33 +50,23 @@ 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.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; 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 +85,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 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; @@ -95,7 +99,10 @@ public class MCPServerStartCommand 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 FcliActionState sharedFunctionActionState = new FcliActionState(); + private final Supplier sharedFunctionFrameSupplier = + () -> FcliExecutionContextHolder.push(new FcliExecutionContext(sharedIsolationScope, sharedFunctionActionState)); private MCPJobManager jobManager; @Override @@ -179,6 +186,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() ) { @@ -193,7 +201,7 @@ private List createToolSpecs() { } } // Job management tool - result.add(jobManager.getJobToolSpecification()); + result.add(wrapToolSpec(jobManager.getJobToolSpecification())); return result; } @@ -205,7 +213,7 @@ private List createResourceTemplateSpecs() { result.addAll(createImportedFunctionResourceTemplateSpecs(action)); } } - return result; + return result.stream().map(this::wrapResourceTemplateSpec).toList(); } private List createActionToolSpecs() { @@ -213,7 +221,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(); } @@ -231,6 +239,24 @@ 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) { + try (var frame = FcliExecutionContextHolder.push(new FcliExecutionContext(sharedIsolationScope, new FcliActionState()))) { + return supplier.get(); + } + } + private Action loadImportedAction(String importFile) { var sources = ActionSource.externalActionSources(importFile); var validationHandler = ActionValidationHandler.WARN; @@ -243,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(); @@ -263,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()); } @@ -281,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-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 new file mode 100644 index 00000000000..9d66fea498a --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPImportedActionMcpSpecsFactory.java @@ -0,0 +1,161 @@ +/* + * 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.mcp.helper; + +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; +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; +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; + +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 Supplier frameSupplier; + + 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(Action action, String functionName, ActionFunction function) { + 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; + 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(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, frameSupplier); + 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( + List tools, + List resourceTemplates + ) {} +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPJobManager.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPJobManager.java similarity index 94% rename from fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPJobManager.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPJobManager.java index 0c938ff81f3..781f6c1eaae 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp; +package com.fortify.cli.ai_assist.mcp.helper; import java.time.Instant; import java.util.ArrayList; @@ -31,8 +31,9 @@ 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.ai_assist.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; import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; @@ -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,20 @@ 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(() -> { + try (var frame = FcliExecutionContextHolder.push(parentContext.createChild())) { + return executeWork(exchange, exec, work, sendNotifications); + } + }, workExecutor) .whenComplete((res, t) -> handleJobCompletion(exchange, exec, res, t, sendNotifications)); exec.future = future; return future; @@ -170,7 +181,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 +219,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 +302,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 +344,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 +527,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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/MCPReflectConfigGenerator.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/MCPReflectConfigGenerator.java index f25d07ed44b..099ee603838 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp; +package com.fortify.cli.ai_assist.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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/AbstractMCPToolArgHandlerFcli.java index f45fc434f1b..35fad69c69a 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.ai_assist.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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/IMCPToolArgHandler.java index e7db2c6b856..144fa2a994c 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.ai_assist.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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerActionOption.java index 944fc0340c0..059de4fa1c3 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.ai_assist.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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerFcliOption.java index 9819a2b800b..1fae4a99137 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerFcliParam.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerFcliParam.java index 41442642663..b5a3df7b3a9 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerPaging.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerPaging.java index aeb121280c3..d5a1a24a6cc 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/arg/MCPToolArgHandlerPaging.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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.ai_assist.mcp.helper.arg; import java.util.Map; +import com.fortify.cli.ai_assist.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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerQuery.java index f4802931b61..326b4ae0ebc 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.ai_assist.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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlers.java index 2b673c999f1..60f3dd5507d 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.arg; +package com.fortify.cli.ai_assist.mcp.helper.arg; import java.util.ArrayList; import java.util.LinkedHashMap; 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 new file mode 100644 index 00000000000..955e61453a6 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/JdkHttpServerMcpStatelessTransport.java @@ -0,0 +1,253 @@ +/* + * 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.mcp.helper.http; + +import java.io.IOException; +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.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; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; + +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 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"; + private static final String INITIALIZED_NOTIFICATION_METHOD = "notifications/initialized"; + + 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(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; + 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 (!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 false; + } + if (!exchange.getRequestURI().getPath().equals(mcpEndpoint)) { + sendPlainError(exchange, 404, "Not found"); + return false; + } + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendPlainError(exchange, 405, "Method not allowed"); + return false; + } + if (mcpHandler == null) { + sendPlainError(exchange, 503, "MCP handler not initialized"); + return false; + } + 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 false; + } + return true; + } + + 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; } + var body = new String(bodyBytes, StandardCharsets.UTF_8); + var message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); + 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") + .build()); + } + } catch (IllegalArgumentException e) { + sendMcpError(exchange, 400, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .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 server error") + .build()); + } + } + + 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 { + 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(effectiveLimit + 1, Integer.MAX_VALUE); + var bytes = exchange.getRequestBody().readNBytes(limit); + if ( bytes.length > effectiveLimit ) { + 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"; + } + 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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpAuthHeaderParser.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpAuthHeaderParser.java new file mode 100644 index 00000000000..1aafc6877b1 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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.ai_assist.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-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 new file mode 100644 index 00000000000..99e524833c5 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfig.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.ai_assist.mcp.helper.http; + +import java.net.InetSocketAddress; +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.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 kong.unirest.Config; +import lombok.Data; +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 { + private ServerConfig server = new ServerConfig(); + private JobsConfig jobs = new JobsConfig(); + 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 ServerConfig { + private int port = 8080; + private String bindAddress; + private long maxRequestBodyBytes = 10 * 1024 * 1024; + 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 { + 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 @EqualsAndHashCode(callSuper = true) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FoDConfig extends ConnectionConfig { + private String url; + + @Override + protected int getDefaultSocketTimeoutInMillis() { + return 600000; + } + } + + 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); + jobs.getIsolationScopeTtlInMillis(); // validates isolationScopeTtl period string + switch ( getProduct() ) { + case ssc -> validateSscConfig(); + case fod -> validateFoDConfig(); + } + } + + @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 ) { + 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) { + 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"); + } + if ( StringUtils.isBlank(ssc.getUrl()) ) { + throw new FcliSimpleException("HTTP MCP config ssc.url must be specified"); + } + ssc.getConnectTimeoutInMillis(); + ssc.getSocketTimeoutInMillis(); + } + + private void validateFoDConfig() { + if ( ssc != null ) { + 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"); + } + fod.getConnectTimeoutInMillis(); + fod.getSocketTimeoutInMillis(); + } +} \ 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/MCPServerHttpConfigLoader.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpConfigLoader.java new file mode 100644 index 00000000000..15d8212a6f8 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/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.ai_assist.mcp.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, e); + } + } + + 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-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 new file mode 100644 index 00000000000..0c31ad82f4b --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -0,0 +1,471 @@ +/* + * 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.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.Arrays; +import java.util.HexFormat; +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; + +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; +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; +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._common.session.helper.SSCSessionValidationHelper; +import com.fortify.cli.ssc.access_control.helper.SSCTokenGetOrCreateResponse.SSCTokenData; + +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"; + + // 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"}; + + 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 { + final FcliIsolationScope scope; + volatile long lastAccessTime; + + IsolationScopeEntry(FcliIsolationScope scope) { + this.scope = scope; + this.lastAccessTime = System.currentTimeMillis(); + } + + void updateLastAccess() { + lastAccessTime = System.currentTimeMillis(); + } + + 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}. + * The per-auth-scope {@link FcliActionState} is reused across calls so that + * {@code global.*} action variables persist within the same authenticated identity. + */ + public FcliExecutionContextHolder.ContextFrame getOrCreateFunctionFrame(String 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(ParsedAuthorization auth) { + var authScopeKey = createAuthCacheKey(auth); + var entry = isolationScopeCache.get(authScopeKey); + if ( entry == null ) { + var newEntry = new IsolationScopeEntry(createIsolationScope(authScopeKey, auth)); + var existing = isolationScopeCache.putIfAbsent(authScopeKey, newEntry); + entry = existing != null ? existing : newEntry; + } + entry.updateLastAccess(); + validateAndRefreshSession(entry, auth); + return entry.scope; + } + + /** + * 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); + } + + /** + * 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 -> { + if ( e.getValue().isExpired(ttlMillis) ) { + deleteDirQuietly(e.getValue().scope.getScopedVarsPath()); + return true; + } + return false; + }); + } + + 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, ParsedAuthorization auth) { + var result = new FcliIsolationScope(); + result.setMcpRequestAuthScopeKey(authScopeKey); + result.setTransientSessionDescriptor(createSessionDescriptor(auth)); + result.setScopedVarsPath(mcpScopedVarsRootPath.resolve(authScopeKey.replace("|", "_"))); + 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. + * 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, 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, 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 + // logic here as well + validateSscToken(sscDescriptor); + } + } + } + } + + private FoDSessionDescriptor refreshFoDTokenIfExpired(FoDSessionDescriptor descriptor, ParsedAuthorization auth) { + if ( descriptor.hasActiveCachedTokenResponse() ) { + return descriptor; + } + var fodConfig = config.getFod(); + var urlConfig = UrlConfig.builderFromConnectionConfig(fodConfig) + .url(FoDProductHelper.INSTANCE.getApiUrl(fodConfig.getUrl())) + .build(); + return descriptor.withCachedTokenResponse(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); + } + } + + 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(ParsedAuthorization auth) { + return switch ( auth.product() ) { + case ssc -> createSscAuthCacheKey(auth); + case fod -> createFoDAuthCacheKey(auth); + }; + } + + private String createSscAuthCacheKey(ParsedAuthorization auth) { + return createHashedCacheKey( + "ssc", + auth.sscToken(), + StringUtils.defaultString(auth.scSastClientAuthToken()) + ); + } + + 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); + } + 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 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 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 keys %s, %s, and %s in %s header", + FOD_TENANT_KEY, FOD_USER_KEY, FOD_PAT_KEY, HEADER_AUTH_FOD); + } + } + + 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(ParsedAuthorization auth) { + return switch ( auth.product() ) { + case ssc -> createSscSessionDescriptor(auth); + case fod -> createFoDSessionDescriptor(auth); + }; + } + + private ISessionDescriptor createSscSessionDescriptor(ParsedAuthorization auth) { + var tokenData = new SSCTokenData(); + tokenData.setToken(auth.sscToken().toCharArray()); + var sscConfig = config.getSsc(); + var scSastClientAuthToken = StringUtils.firstNonBlank( + sscConfig.getScSastClientAuthToken(), + auth.scSastClientAuthToken() + ); + return SSCAndScanCentralSessionDescriptor.create( + new HttpMcpSscUrlConfig(sscConfig), + new HttpMcpSscCredentialsConfig( + tokenData.getToken(), + StringUtils.isBlank(scSastClientAuthToken) ? null : scSastClientAuthToken.toCharArray() + ) + ); + } + + 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(auth, urlConfig)); + } + + 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( + auth.fodClientId(), + auth.fodClientSecret() + ), + 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 { + 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 Set.of(); + } + + @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; + } + } +} diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/ParsedAuthorization.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/ParsedAuthorization.java new file mode 100644 index 00000000000..861b1b37644 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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.ai_assist.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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/AbstractMCPToolFcliRunner.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/AbstractMCPToolFcliRunner.java index 288a0875644..1904bbba336 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.ai_assist.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.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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/IMCPToolRunner.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/IMCPToolRunner.java index 40ac8538012..3fe2d09f299 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPResourceFcliRunnerFunction.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java similarity index 92% 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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java index 7f0e407ac8a..2dfebad689d 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPResourceFcliRunnerFunction.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPResourceFcliRunnerFunction.java @@ -10,13 +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.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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolAsyncJobManager.java similarity index 64% 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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolAsyncJobManager.java index c7f980f7c14..7279a9949dd 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolAsyncJobManager.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolAsyncJobManager.java @@ -10,28 +10,37 @@ * 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.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.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.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; +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 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; @@ -42,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()) @@ -57,31 +67,40 @@ public MCPToolResult getCached(String jobId) { return builder.build(); } + public String getJobToken(String jobId) { + return getScopeState().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 * 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); + 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 - delegate.startBackground(jobId, task, cachingListener, "mcp:" + jobId); + delegate.startBackground(AsyncJobManager.TaskDescriptor.builder() + .jobId(jobId) + .task(task) + .listener(scopeState.cachingListener) + .description("mcp:" + jobId) + .build()); var future = delegate.getFuture(jobId); if (future != null) { var jobToken = jobManager.trackFuture("async_job", future, - () -> cachingListener.getLoadedCount(jobId)); - jobTokens.put(jobId, jobToken); - future.whenComplete((r, t) -> jobTokens.remove(jobId)); + () -> 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. */ @@ -94,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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliPagedHelper.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliPagedHelper.java similarity index 87% 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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliPagedHelper.java index b0fc45d9e01..d13171b72a0 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.ai_assist.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.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; @@ -27,6 +27,10 @@ * 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. + *

+ * 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 */ @@ -90,7 +94,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 +117,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 +162,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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerAction.java index 71d6a83d8fc..ab1253f9111 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerAction.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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.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.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; -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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerFunction.java index 95db9439636..7350c6bc54e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.ai_assist.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.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; -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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerFunctionStreaming.java index 39ce51d8c90..10ce140e2b4 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerFunctionStreaming.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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.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.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; -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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerHelper.java index bb7051fa3d4..7e0fe764899 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.ai_assist.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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerPlainText.java index 1c051c8bc88..bec9eb64d10 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.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.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecords.java rename to fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerRecords.java index 4395e15be0f..97107065d3d 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.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.util.mcp_server.helper.mcp.MCPJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.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-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecordsPaged.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolFcliRunnerRecordsPaged.java index beb465cb7a6..74349da1ff6 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/runner/MCPToolFcliRunnerRecordsPaged.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/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.ai_assist.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.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; 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-ai-assist/src/main/java/com/fortify/cli/ai_assist/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-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/runner/MCPToolResult.java index 58cc03d03e8..1be0b2d8d00 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/helper/mcp/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.util.mcp_server.helper.mcp.runner; +package com.fortify.cli.ai_assist.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-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..f5a2be05a48 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties @@ -0,0 +1,88 @@ +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 = 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 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. +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 = 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 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.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-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 new file mode 100644 index 00000000000..9d47df0e570 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-fod.yaml @@ -0,0 +1,43 @@ +server: + port: 8080 + # bindAddress: "" # Network interface to bind; empty = all interfaces (0.0.0.0) + # 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 + # keyPassword: ${#env('KEY_PASSWORD')} # Private-key password; defaults to keystorePassword + # keystoreType: PKCS12 # Keystore type (PKCS12 or JKS; default: PKCS12) + +imports: + - actions/http-fod.yaml + +# Optional jobs settings (shown with default values): +# jobs: +# +# # 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: 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. +# 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: + url: https://api.ams.fortify.com + connectTimeout: 30s + socketTimeout: 10m + insecureModeEnabled: false 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 new file mode 100644 index 00000000000..ea2770c1958 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/mcp/config/mcp-http-config-ssc.yaml @@ -0,0 +1,44 @@ +server: + port: 8080 + # bindAddress: "" # Network interface to bind; empty = all interfaces (0.0.0.0) + # 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 + # keyPassword: ${#env('KEY_PASSWORD')} # Private-key password; defaults to keystorePassword + # keystoreType: PKCS12 # Keystore type (PKCS12 or JKS; default: PKCS12) + +imports: + - actions/http-ssc.yaml + +# Optional jobs settings (shown with default values): +# jobs: +# +# # 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: 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. +# 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: + url: https://ssc.example.com + connectTimeout: 30s + socketTimeout: 10m + insecureModeEnabled: false + scSastClientAuthToken: ${#env('SSC_SAST_CLIENT_AUTH_TOKEN')} diff --git a/fcli-core/fcli-ai-assist/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 new file mode 100644 index 00000000000..80c22aa07ae --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/helper/http/MCPServerHttpSessionDescriptorResolverTest.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.ai_assist.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; + +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 = sscConfig("https://ssc.example.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); + var resolver = new MCPServerHttpSessionDescriptorResolver(config); + + 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")); + assertFalse(cacheKey.contains("sast-token")); + } + + @Test + void createAuthCacheKeyHashesFoDClientCredentials() { + var config = fodConfig("https://api.ams.fortify.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); + var resolver = new MCPServerHttpSessionDescriptorResolver(config); + + 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")); + assertFalse(cacheKey.contains("client-secret")); + } + + @Test + void createAuthCacheKeyRejectsMixedFoDAuthModes() { + 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(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 = sscConfig("https://ssc.example.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); + var resolver = new MCPServerHttpSessionDescriptorResolver(config); + + 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(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")); + assertFalse(cacheKeyA.equals(cacheKeyB)); + } + + @Test + void createAuthCacheKeyRejectsInvalidEscapeSequence() { + var config = sscConfig("https://ssc.example.com"); + var parser = new MCPServerHttpAuthHeaderParser(config); + + var exception = assertThrows(FcliSimpleException.class, () -> parser.parse(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) { + 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-ai-assist/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 new file mode 100644 index 00000000000..48b547cd3ce --- /dev/null +++ b/fcli-core/fcli-ai-assist/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.ai_assist.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-ai-assist/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 new file mode 100644 index 00000000000..e9c3cb34b35 --- /dev/null +++ b/fcli-core/fcli-ai-assist/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.ai_assist.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-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java similarity index 71% rename from fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPJobManagerTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPJobManagerTest.java index f7c2acdbdb0..97f8f15c284 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/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.util.mcp_server.unit; +package com.fortify.cli.ai_assist.mcp.unit; import static org.junit.jupiter.api.Assertions.*; @@ -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.util._common.helper.AsyncJobManager; -import com.fortify.cli.util.mcp_server.helper.mcp.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; +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-ai-assist/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 new file mode 100644 index 00000000000..c4c2784cad8 --- /dev/null +++ b/fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPServerHttpConfigLoaderTest.java @@ -0,0 +1,75 @@ +/* + * 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.mcp.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.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; + +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, """ + 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.getServer().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 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, """ + imports: + - fod-actions.yaml + """); + + var exception = assertThrows(FcliSimpleException.class, () -> MCPServerHttpConfigLoader.load(configFile)); + + assertEquals("HTTP MCP config must specify exactly one of ssc or fod section", exception.getMessage()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/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-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPToolArgHandlersTest.java rename to fcli-core/fcli-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolArgHandlersTest.java index d1a08e9444e..21b7bcfdc93 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/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.util.mcp_server.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.util.mcp_server.helper.mcp.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-util/src/test/java/com/fortify/cli/util/mcp_server/unit/MCPToolFcliRunnerRecordsTest.java b/fcli-core/fcli-ai-assist/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-ai-assist/src/test/java/com/fortify/cli/agent/mcp/unit/MCPToolFcliRunnerRecordsTest.java index 4854a0e216a..df0d5e3f1e7 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/mcp_server/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.util.mcp_server.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,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.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; import picocli.CommandLine.Command; diff --git a/fcli-core/fcli-app/build.gradle.kts b/fcli-core/fcli-app/build.gradle.kts index 92328e390ad..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","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.util.mcp_server.helper.mcp.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/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/_main/cli/cmd/FCLIRootCommands.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java index 3d319940d45..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,6 +12,7 @@ */ package com.fortify.cli.app._main.cli.cmd; +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; @@ -46,6 +47,7 @@ versionProvider = FortifyCLIVersionProvider.class, subcommands = { GenericActionCommands.class, + AiAssistCommands.class, AviatorCommands.class, ConfigCommands.class, FoDCommands.class, 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..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 @@ -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; @@ -32,22 +34,49 @@ 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(); + + 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(); 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. @@ -60,36 +89,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 { 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-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/action/cli/cmd/RunBuildTimeFcliAction.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/cli/cmd/RunBuildTimeFcliAction.java index e4c0332bafa..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 @@ -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,9 @@ public static void main(String[] args) { .progressWriter(progressWriter) .onValidationErrors(RunBuildTimeFcliAction::onValidationErrors) .build(); - new ActionRunner(config).run(actionArgs); + try (var frame = FcliExecutionContextHolder.pushNew()) { + new ActionRunner(config).run(actionArgs); + } } } 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/action/runner/ActionFunctionExecutor.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionFunctionExecutor.java index e6e20f26c18..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,11 +12,13 @@ */ package com.fortify.cli.common.action.runner; +import java.util.Map; +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; 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; @@ -24,25 +26,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...)}. - *

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

+ * {@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 FcliExecutionContext sharedContext; + private final Supplier frameSupplier; - public ActionFunctionExecutor(Action action, ActionFunction function, FcliExecutionContext sharedContext) { + public ActionFunctionExecutor(Action action, ActionFunction function, Supplier frameSupplier) { this.action = action; this.function = function; - this.sharedContext = sharedContext; + this.frameSupplier = frameSupplier; } public Action getAction() { @@ -62,8 +73,7 @@ public ActionFunction getFunction() { * For streaming functions: an IActionStepForEachProcessor. */ public Object execute(ObjectNode argsNode) { - FcliExecutionContextHolder.push(sharedContext); - try { + try (var frame = frameSupplier.get()) { var config = ActionRunnerConfig.builder() .action(action) .progressWriter(new ProgressWriterI18n(ProgressWriterType.none, null)) @@ -74,15 +84,13 @@ public Object execute(ObjectNode argsNode) { var fnSpel = new ActionFunctionSpelFunctions(ctx); return fnSpel.call(function.getKey(), argsNode); } - } finally { - FcliExecutionContextHolder.pop(); } } /** * 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/action/runner/ActionRunnerVars.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerVars.java index b6d50cfb2b8..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 @@ -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().getActionState().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/FcliActionState.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java new file mode 100644 index 00000000000..81a02f7c30e --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliActionState.java @@ -0,0 +1,50 @@ +/* + * 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 bag of {@code global.*} action variables shared by related action invocations. + * + *

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 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.
  • + *
+ */ +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..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,26 +45,16 @@ @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 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; @@ -74,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); @@ -87,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)); } @@ -122,26 +132,17 @@ public final Result execute() { private Result call(Callable f) { Result result = null; - boolean pushed = false; try { - if ( createInvocationContext ) { - FcliExecutionContextHolder.pushNew(); - pushed = true; - } - 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, "", ""); + result = OutputHelper.builder() + .stderrType(stderrOutputType) + .stdoutType(resolveStdoutOutputType()) + .build().call(f); + } catch ( Throwable t ) { + if ( t instanceof ExecutionException ) { + t = t.getCause(); } - } 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 @@ -272,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 078d6f4aea4..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 @@ -10,47 +10,119 @@ * 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 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.log.LogMaskContext; import com.fortify.cli.common.rest.unirest.UnirestContext; +import lombok.Getter; + /** - * Per-top-level execution context holding mutable execution-scoped state. - * The {@code globalValues} ObjectNode is backed by a {@link ConcurrentHashMap} - * to allow safe concurrent access from multiple threads (e.g. async jobs, - * server request handlers). + * 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 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.
    • + *
  • + * + *
  • {@link FcliIsolationScope} — groups related invocations that share the same + * auth/session boundary. See {@link FcliIsolationScope} for details. + *
+ * + *

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 { - private final ObjectNode globalValues = new ObjectNode(JsonNodeFactory.instance, new ConcurrentHashMap<>()); - private final UnirestContext unirestContext = new UnirestContext(); +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; // Set of absolute file paths that were saved using ephemeral encryption during this execution private final Set ephemeralEncryptedFiles = ConcurrentHashMap.newKeySet(); - public ObjectNode getGlobalValues() { return globalValues; } - public UnirestContext getUnirestContext() { return unirestContext; } + public FcliExecutionContext() { + 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"); + } + + /** + * 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(), logMaskContext); + } + + /** + * 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. + */ + @Override + public void close() { + unirestContext.close(); + } public String info() { - return String.format("FcliExecutionContext@%s(%d) actionGlobalValues@%s(%d) unirestContext@%s(%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(globalValues)), - globalValues.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()); + unirestContext.getCachedInstanceCount(), + isolationScope.getTransientSessionDescriptors().size(), + isolationScope.getMcpRequestAuthScopeKey() != null ? "set" : "unset"); } /** @@ -61,6 +133,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); 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..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 @@ -15,44 +15,119 @@ 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 * 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.

+ * + *

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 a fresh, empty context and return it. */ - public static FcliExecutionContext pushNew() { - HOLDER.get().push(new FcliExecutionContext()); - return HOLDER.get().peek(); + /** 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); } - /** Pop the current context and return it; returns null if none present. */ + /** + * 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 ContextFrame pushNew() { + var stack = HOLDER.get(); + var context = new FcliExecutionContext(); + stack.push(context); + return new ContextFrame(context); + } + + /** + * 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; } /** - * 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(); } + + /** + * 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); + } + + 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/cli/util/FcliExecutionStrategy.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliExecutionStrategy.java index 7091faa8958..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,9 +75,52 @@ public FcliExecutionStrategy(IExecutionStrategy delegate) { public int execute(ParseResult parseResult) throws CommandLine.ExecutionException { var leaf = getLeafParseResult(parseResult); var leafSpec = leaf.commandSpec(); - var execCtx = FcliExecutionContextHolder.current(); + 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 { 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..7303b32660b --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/FcliIsolationScope.java @@ -0,0 +1,86 @@ +/* + * 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.nio.file.Path; +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 grouping related invocations under the same auth/session context. + * + *

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; + @Getter private volatile Path scopedVarsPath; + @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; + } + + 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/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-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/cli/util/StdioHelper.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/StdioHelper.java index 07f9fade774..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 @@ -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,9 +21,12 @@ 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; +import picocli.CommandLine.Help.Ansi; + /** * Central manager for fcli stdio delegation, masking, and progress streams. * @@ -43,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 */ @@ -57,7 +62,9 @@ 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; private static PrintStream rawErr = System.err; private static PrintStream maskedOut = System.out; @@ -71,8 +78,12 @@ 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.OFF; 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(() -> { @@ -105,6 +116,13 @@ public static synchronized void uninstall() { installed = false; } + /** + * Return the resolved ANSI mode, detected before streams were replaced. + * 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; } + /** * Return the raw, unmasked {@code System.out} captured before installation. *

Only for protocol I/O (JSON-RPC in RPC/MCP servers). @@ -134,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); } @@ -143,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); } @@ -211,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); } 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 59% 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 8919f9fee87..4dc8f1a74f9 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; @@ -20,7 +20,9 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +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,7 +51,22 @@ public static class Config { @Builder.Default int bgThreads = DEFAULT_BG_THREADS; } - private final Map jobs = new ConcurrentHashMap<>(); + /** + * 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 = ""; + Supplier executionContextSupplier; + } + + private static final class ScopeState { + private final Map jobs = new ConcurrentHashMap<>(); + } + private final ExecutorService backgroundExecutor; public AsyncJobManager() { @@ -66,49 +83,60 @@ 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 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(); - FcliExecutionContextHolder.pushNew(); - // 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); - } - }); - if (!Thread.currentThread().isInterrupted()) { - int exitCode = result.getExitCode(); - String stderr = result.getErr(); - String stdout = result.getOut(); - if (stdout != null && !stdout.isBlank()) { - entry.stdout = stdout; + 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); } - 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); @@ -117,26 +145,11 @@ 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. */ 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) { @@ -145,7 +158,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; } @@ -154,26 +167,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; @@ -181,18 +194,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-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 69% 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..2a2432aa0a0 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,13 +10,13 @@ * 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.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. */ 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 96% 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..49c53836ffd 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; @@ -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-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/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-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/cli/mixin/AbstractSessionDescriptorSupplierMixin.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/session/cli/mixin/AbstractSessionDescriptorSupplierMixin.java index 5c4a22e79c3..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 @@ -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().getIsolationScope().getTransientSessionDescriptor(getSessionDescriptorType()); + } public abstract ISessionNameSupplier getSessionNameSupplier(); + protected abstract String getSessionDescriptorType(); protected abstract D getSessionDescriptor(String sessionName); } 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); 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..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; @@ -55,11 +54,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 +86,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 +118,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); @@ -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; } } 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"); } 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..26b8cff382c --- /dev/null +++ b/fcli-core/fcli-common/src/test/java/com/fortify/cli/common/cli/util/FcliExecutionContextTest.java @@ -0,0 +1,130 @@ +/* + * 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.assertThrows; +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() { + 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")); + + 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")); + + context.getIsolationScope().clearTransientSessionDescriptor("SSC"); + + assertNull(context.getIsolationScope().getTransientSessionDescriptor("SSC")); + assertSame(fodDescriptor, context.getIsolationScope().getTransientSessionDescriptor("FoD")); + + context.getIsolationScope().clearTransientSessionDescriptors(); + + assertTrue(context.getIsolationScope().getTransientSessionDescriptors().isEmpty()); + } + } + + @Test + void transientSessionDescriptorConvenienceSetterIndexesByType() { + try (var context = new FcliExecutionContext()) { + var descriptor = new DummySessionDescriptor("dummy"); + + context.getIsolationScope().setTransientSessionDescriptor(descriptor); + + assertSame(descriptor, context.getIsolationScope().getTransientSessionDescriptor("dummy")); + } + } + + @Test + void pushNewAlwaysCreatesAFreshContext() { + FcliExecutionContextHolder.pushNew(); + try { + var parent = FcliExecutionContextHolder.current(); + parent.getIsolationScope().setMcpRequestAuthScopeKey("ssc|abc123"); + FcliExecutionContextHolder.pushNew(); + try { + var child = FcliExecutionContextHolder.current(); + // 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(); + } + } finally { + FcliExecutionContextHolder.pop(); + } + } + + @Test + void createChildInheritsIsolationScopeAndCreatesFreshActionState() { + try (var parent = new FcliExecutionContext(); var child = parent.createChild()) { + assertSame(parent.getIsolationScope(), child.getIsolationScope()); + assertTrue(child.getActionState().getGlobalActionValues().isEmpty()); + } + } + + @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; + + 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..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().getGlobalValues().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().getGlobalValues().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 new file mode 100644 index 00000000000..36382e6b0a3 --- /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().getIsolationScope().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().getIsolationScope().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/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/_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-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() { 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..fcc86265013 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeDefinitionHelper.java @@ -0,0 +1,251 @@ +/* + * 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.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; + +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 (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()); + 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..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 @@ -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,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); + 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; 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. 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(), 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) { 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..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,20 +16,20 @@ 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; 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 +47,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; @@ -142,7 +140,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); @@ -174,14 +179,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 +262,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 +271,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 +284,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 +320,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 +337,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); + try (InputStream is = FileUtils.openResourceInputStream(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 +391,111 @@ 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(), 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.openResourceInputStream(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; + } + 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 +522,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.openResourceInputStream(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 +553,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 +583,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 +611,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 +637,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)); } @@ -556,7 +653,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) { 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/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..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,7 +19,7 @@ @Command( name = "mcp-server", subcommands = { - MCPServerStartCommand.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..29730b46a4a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/mcp_server/cli/cmd/MCPServerStartDeprecatedCommand.java @@ -0,0 +1,56 @@ +/* + * 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.Arrays; +import java.util.List; +import java.util.stream.Stream; + +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.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; + +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 implements IFcliExecutionContextManager { + @Unmatched private List delegatedArgs; + + @Override + public Integer call() { + 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() + .args(allArgs) + .stdoutOutputType(OutputType.show) + .stderrOutputType(OutputType.show) + .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..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,13 +17,14 @@ 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; 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; @@ -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/RPCJobEventListenerFactory.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCJobEventListenerFactory.java index 208c53efb30..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,11 +16,12 @@ 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; 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; @@ -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/RPCMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCMethodHandlerFcliExecute.java index 8666effca27..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; @@ -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..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; @@ -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); 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..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 com.fortify.cli.util._common.helper.CachingJobEventListener; -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 29b9c50dfff..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,9 +13,10 @@ 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; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.CachingJobEventListener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -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/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..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,9 +13,10 @@ 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; -import com.fortify.cli.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.CachingJobEventListener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -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 dad7097e981..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,14 +15,18 @@ 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; 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.util._common.helper.AsyncJobManager; -import com.fortify.cli.util._common.helper.CachingJobEventListener; +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; import lombok.extern.slf4j.Slf4j; @@ -45,16 +49,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 +75,11 @@ public AsyncJobManager getAsyncJobManager() { } public CachingJobEventListener getCachingListener() { - return cachingListener; + return isolationScope.getOrCreateScopedState(CachingJobEventListener.class, CachingJobEventListener::new); + } + + FcliIsolationScope getIsolationScope() { + return isolationScope; } /** @@ -92,8 +100,10 @@ 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 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<>(); @@ -117,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()); } @@ -132,23 +142,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/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/RPCServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/RPCServer.java index 18ff62ede41..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 @@ -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,10 @@ private RPCResponse executeMethod(RPCRequest request) { } try { - JsonNode result = handler.execute(request.params()); + JsonNode result; + try (var frame = FcliExecutionContextHolder.push(new FcliExecutionContext(registry.getIsolationScope(), new FcliActionState()))) { + result = handler.execute(request.params()); + } return RPCResponse.success(request.id(), result); } catch (RPCMethodException e) { return RPCResponse.error(request.id(), e.toJsonRpcError()); 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 9ce6b568038..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 @@ -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. @@ -105,6 +58,36 @@ 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%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. 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\ + %nimports:\ + %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. # fcli util rpc-server fcli.util.rpc-server.usage.header = (PREVIEW) Manage fcli JSON-RPC server for IDE plugin integration @@ -120,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\ 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..4819bac3151 --- /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(["ai-assist", "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.isAlive() ) { + 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/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/AiAssistExtensionsSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy new file mode 100644 index 00000000000..c56a6e5ffa7 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/AiAssistExtensionsSpec.groovy @@ -0,0 +1,140 @@ +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", + {it.expectSuccess(false)}) + then: + verifyAll(result.stderr) { + it.any { it.contains("--assistants") || it.contains("--auto-detect") || it.contains("--dir") } + } + } + + def "setup-unknown-assistant"() { + when: + def result = Fcli.run("ai-assist extensions setup --assistants unknown-assistant --dry-run", + {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", + {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", + {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.skills.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", + {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-dir"() { + def targetDir = "${baseDir}/skills" + when: + def result = Fcli.run("ai-assist extensions uninstall --dir ${targetDir} --content-types skills", + {it.expectZeroExitCode()}) + then: + verifyAll(result.stdout) { + size() > 0 + it[0].replace(' ', '').equals("ContenttypeTargetdirFilecountAction") + it[1].contains("REMOVED") + } + // Manifest should be gone + !Files.exists(Path.of(targetDir, ".fortify-extensions.skills.json")) + } +} 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") + } + } +} 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..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 @@ -23,8 +23,13 @@ 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 = "") { + // 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()) @@ -124,4 +129,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/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..e2008cf924f --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/fod/FoDMCPServerHttpSpec.groovy @@ -0,0 +1,84 @@ +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 = """ + server: + 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/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 new file mode 100644 index 00000000000..d39677a9d26 --- /dev/null +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCMCPServerHttpSpec.groovy @@ -0,0 +1,126 @@ +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("server:\n") + .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", "UnifiedLoginToken", + "--expire-in=5m", + "--description=${tokenName}", + "--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(";") + } +} 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} 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 diff --git a/gradle.properties b/gradle.properties index 4adf83d5896..6c5f4eda756 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 +fcliAiAssistRef=:fcli-core:fcli-ai-assist fcliAviatorRef=:fcli-core:fcli-aviator fcliAviatorCommonRef=:fcli-core:fcli-aviator-common fcliCommonRef=:fcli-core:fcli-common