diff --git a/README.md b/README.md
index fa2bebf..d34bbcb 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ The current `golemcore/*` modules in this repository are:
| `golemcore/obsidian` | Obsidian vault plugin backed by obsidian-local-rest-api. |
| `golemcore/perplexity-sonar` | Perplexity Sonar grounded-answer plugin with configurable model selection and synchronous completions. |
| `golemcore/pinchtab` | PinchTab browser automation plugin for navigation, snapshots, actions, text extraction, and screenshots. |
+| `golemcore/s3` | S3-compatible object storage plugin backed by the MinIO Java SDK. |
| `golemcore/tavily-search` | Tavily-backed web search tool plugin with configurable search depth and answer generation. |
| `golemcore/telegram` | Telegram channel, invite onboarding, confirmations, and plan approval integration. |
| `golemcore/slack` | Slack Socket Mode channel plugin with thread follow-ups, confirmations, and plan approval UI. |
diff --git a/golemcore/s3/plugin.yaml b/golemcore/s3/plugin.yaml
new file mode 100644
index 0000000..302364e
--- /dev/null
+++ b/golemcore/s3/plugin.yaml
@@ -0,0 +1,12 @@
+id: golemcore/s3
+provider: golemcore
+name: s3
+version: 1.0.0
+pluginApiVersion: 1
+engineVersion: ">=0.0.0 <1.0.0"
+entrypoint: me.golemcore.plugins.golemcore.s3.S3PluginBootstrap
+description: S3 object plugin backed by the MinIO Java SDK.
+sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/s3
+license: Apache-2.0
+maintainers:
+ - alexk-dev
diff --git a/golemcore/s3/pom.xml b/golemcore/s3/pom.xml
new file mode 100644
index 0000000..053693b
--- /dev/null
+++ b/golemcore/s3/pom.xml
@@ -0,0 +1,110 @@
+
+
+ 4.0.0
+
+
+ me.golemcore.plugins
+ golemcore-plugins
+ 1.0.0
+ ../../pom.xml
+
+
+ 1.0.0
+ golemcore-s3-plugin
+ golemcore/s3
+ S3 object plugin for GolemCore backed by the MinIO Java SDK
+
+
+ golemcore
+ s3
+ ../../misc/formatter_eclipse.xml
+ 9.0.0
+
+
+
+
+ me.golemcore.plugins
+ golemcore-plugin-extension-api
+ provided
+
+
+ me.golemcore.plugins
+ golemcore-plugin-runtime-api
+ provided
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+ org.springframework
+ spring-context
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ io.minio
+ minio
+ ${minio.version}
+
+
+ com.squareup.okhttp3
+ okhttp-jvm
+ ${okhttp.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ src/main/resources
+
+
+ ${project.basedir}
+
+ plugin.yaml
+
+ META-INF/golemcore
+
+
+
+
+ net.revelc.code.formatter
+ formatter-maven-plugin
+
+
+ maven-shade-plugin
+
+
+ maven-antrun-plugin
+
+
+ copy-plugin-artifact
+ package
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesService.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesService.java
new file mode 100644
index 0000000..be58e12
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesService.java
@@ -0,0 +1,597 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import me.golemcore.plugin.api.extension.model.Attachment;
+import me.golemcore.plugin.api.extension.model.ToolFailureKind;
+import me.golemcore.plugin.api.extension.model.ToolResult;
+import me.golemcore.plugins.golemcore.s3.support.S3MimeTypes;
+import me.golemcore.plugins.golemcore.s3.support.S3MinioClient;
+import me.golemcore.plugins.golemcore.s3.support.S3ObjectContent;
+import me.golemcore.plugins.golemcore.s3.support.S3ObjectInfo;
+import me.golemcore.plugins.golemcore.s3.support.S3PathValidator;
+import me.golemcore.plugins.golemcore.s3.support.S3StorageException;
+import org.springframework.stereotype.Service;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+public class S3FilesService {
+
+ private static final long BYTES_PER_KB = 1024;
+ private static final Set TEXT_MIME_TYPES = Set.of(
+ "application/json",
+ "application/ld+json",
+ "application/xml",
+ "application/x-yaml",
+ "application/yaml",
+ "application/javascript",
+ "application/x-javascript",
+ "image/svg+xml");
+ private static final Set TEXT_FILE_EXTENSIONS = Set.of(
+ "txt",
+ "md",
+ "markdown",
+ "json",
+ "xml",
+ "yaml",
+ "yml",
+ "csv",
+ "tsv",
+ "log",
+ "properties",
+ "java",
+ "kt",
+ "groovy",
+ "js",
+ "ts",
+ "jsx",
+ "tsx",
+ "py",
+ "rb",
+ "go",
+ "rs",
+ "c",
+ "cc",
+ "cpp",
+ "h",
+ "hpp",
+ "html",
+ "css",
+ "scss",
+ "sql",
+ "sh",
+ "bash",
+ "zsh",
+ "ini",
+ "toml");
+
+ private final S3MinioClient client;
+ private final S3PluginConfigService configService;
+ private final S3PathValidator pathValidator = new S3PathValidator();
+
+ public S3FilesService(S3MinioClient client, S3PluginConfigService configService) {
+ this.client = client;
+ this.configService = configService;
+ }
+
+ public S3PluginConfig getConfig() {
+ return configService.getConfig();
+ }
+
+ public ToolResult listDirectory(String path) {
+ try {
+ String normalizedPath = pathValidator.normalizeOptionalPath(path);
+ S3ObjectInfo object = client.findObject(normalizedPath);
+ if (object != null) {
+ return executionFailure("Not a directory: " + displayPath(normalizedPath));
+ }
+ if (!normalizedPath.isBlank() && !client.directoryExists(normalizedPath)) {
+ return executionFailure("Directory not found: " + normalizedPath);
+ }
+ List entries = client.listDirectory(normalizedPath);
+ StringBuilder output = new StringBuilder();
+ output.append("Directory: ").append(displayPath(normalizedPath)).append('\n');
+ output.append("Entries: ").append(entries.size()).append("\n\n");
+ for (S3ObjectInfo entry : entries) {
+ if (entry.directory()) {
+ output.append("[DIR] ").append(entry.name()).append("/\n");
+ } else {
+ output.append("[FILE] ").append(entry.name()).append(" (")
+ .append(formatSize(entry.size() != null ? entry.size() : 0L))
+ .append(")\n");
+ }
+ }
+ Map data = new LinkedHashMap<>();
+ data.put("path", normalizedPath);
+ data.put("entries", entries.stream().map(this::toObjectInfo).toList());
+ return ToolResult.success(output.toString(), data);
+ } catch (IllegalArgumentException | S3StorageException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult fileInfo(String path) {
+ try {
+ String normalizedPath = pathValidator.normalizeRequiredPath(path);
+ S3ObjectInfo object = client.findObject(normalizedPath);
+ if (object != null) {
+ return buildObjectInfoResult(object);
+ }
+ if (!client.directoryExists(normalizedPath)) {
+ return executionFailure("Path not found: " + normalizedPath);
+ }
+ S3ObjectInfo marker = client.findDirectoryMarker(normalizedPath);
+ S3ObjectInfo directory = marker != null
+ ? marker
+ : new S3ObjectInfo(
+ getConfig().getBucket(),
+ normalizedPath,
+ leafName(normalizedPath),
+ true,
+ null,
+ null,
+ null,
+ null);
+ return buildObjectInfoResult(directory);
+ } catch (IllegalArgumentException | S3StorageException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult readFile(String path) {
+ try {
+ String normalizedPath = pathValidator.normalizeRequiredPath(path);
+ S3ObjectInfo object = requireObject(normalizedPath);
+ long size = object.size() != null ? object.size() : 0L;
+ if (size > getConfig().getMaxDownloadBytes()) {
+ return executionFailure("File too large to read directly: " + normalizedPath + " (" + formatSize(size)
+ + ")");
+ }
+ S3ObjectContent content = client.readObject(normalizedPath);
+ boolean textFile = isTextFile(normalizedPath, content.contentType(), content.bytes());
+ if (textFile) {
+ return buildTextReadResult(content);
+ }
+ return buildBinaryReadResult(content, size);
+ } catch (IllegalArgumentException | S3StorageException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult writeFile(String path, String content, String contentBase64, boolean append) {
+ if (!Boolean.TRUE.equals(getConfig().getAllowWrite())) {
+ return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 write is disabled in plugin settings");
+ }
+ try {
+ String normalizedPath = pathValidator.normalizeRequiredPath(path);
+ byte[] payload = resolveWritePayload(content, contentBase64);
+ byte[] finalPayload = append ? appendToExistingContent(normalizedPath, payload) : payload;
+ client.writeObject(normalizedPath, finalPayload,
+ resolveContentType(normalizedPath, content, contentBase64));
+ Map data = new LinkedHashMap<>();
+ data.put("path", normalizedPath);
+ data.put("size", finalPayload.length);
+ data.put("operation", append ? "append" : "write");
+ return ToolResult.success(
+ "Successfully " + (append ? "appended to" : "written") + " object: " + normalizedPath,
+ data);
+ } catch (IllegalArgumentException | S3StorageException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult createDirectory(String path) {
+ if (!Boolean.TRUE.equals(getConfig().getAllowWrite())) {
+ return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 write is disabled in plugin settings");
+ }
+ try {
+ String normalizedPath = pathValidator.normalizeRequiredPath(path);
+ S3ObjectInfo object = client.findObject(normalizedPath);
+ if (object != null) {
+ return executionFailure("Path exists but is not a directory: " + normalizedPath);
+ }
+ if (client.directoryExists(normalizedPath)) {
+ return ToolResult.success("Directory already exists: " + normalizedPath,
+ Map.of("path", normalizedPath));
+ }
+ client.createDirectoryMarker(normalizedPath);
+ return ToolResult.success("Created directory: " + normalizedPath, Map.of("path", normalizedPath));
+ } catch (IllegalArgumentException | S3StorageException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult delete(String path) {
+ if (!Boolean.TRUE.equals(getConfig().getAllowDelete())) {
+ return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 delete is disabled in plugin settings");
+ }
+ try {
+ String normalizedPath = pathValidator.normalizeRequiredPath(path);
+ S3ObjectInfo object = client.findObject(normalizedPath);
+ if (object != null) {
+ client.deleteObject(normalizedPath);
+ return ToolResult.success("Deleted: " + normalizedPath, Map.of("path", normalizedPath));
+ }
+ if (!client.directoryExists(normalizedPath)) {
+ return executionFailure("Path not found: " + normalizedPath);
+ }
+ deleteDirectory(normalizedPath);
+ return ToolResult.success("Deleted: " + normalizedPath, Map.of("path", normalizedPath));
+ } catch (IllegalArgumentException | S3StorageException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult move(String path, String targetPath) {
+ if (!Boolean.TRUE.equals(getConfig().getAllowMove())) {
+ return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 move is disabled in plugin settings");
+ }
+ try {
+ String normalizedPath = pathValidator.normalizeRequiredPath(path);
+ String normalizedTargetPath = pathValidator.normalizeRequiredPath(targetPath);
+ if (normalizedPath.equals(normalizedTargetPath)) {
+ return executionFailure("Source and target paths must differ");
+ }
+ ensureTargetAbsent(normalizedTargetPath);
+ S3ObjectInfo object = client.findObject(normalizedPath);
+ if (object != null) {
+ client.copyObject(normalizedPath, normalizedTargetPath);
+ client.deleteObject(normalizedPath);
+ } else {
+ copyDirectory(normalizedPath, normalizedTargetPath);
+ deleteDirectory(normalizedPath);
+ }
+ Map data = new LinkedHashMap<>();
+ data.put("path", normalizedPath);
+ data.put("target_path", normalizedTargetPath);
+ return ToolResult.success("Moved: " + normalizedPath + " -> " + normalizedTargetPath, data);
+ } catch (IllegalArgumentException | S3StorageException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ public ToolResult copy(String path, String targetPath) {
+ if (!Boolean.TRUE.equals(getConfig().getAllowCopy())) {
+ return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 copy is disabled in plugin settings");
+ }
+ try {
+ String normalizedPath = pathValidator.normalizeRequiredPath(path);
+ String normalizedTargetPath = pathValidator.normalizeRequiredPath(targetPath);
+ if (normalizedPath.equals(normalizedTargetPath)) {
+ return executionFailure("Source and target paths must differ");
+ }
+ ensureTargetAbsent(normalizedTargetPath);
+ S3ObjectInfo object = client.findObject(normalizedPath);
+ if (object != null) {
+ client.copyObject(normalizedPath, normalizedTargetPath);
+ } else {
+ copyDirectory(normalizedPath, normalizedTargetPath);
+ }
+ Map data = new LinkedHashMap<>();
+ data.put("path", normalizedPath);
+ data.put("target_path", normalizedTargetPath);
+ return ToolResult.success("Copied: " + normalizedPath + " -> " + normalizedTargetPath, data);
+ } catch (IllegalArgumentException | S3StorageException ex) {
+ return executionFailure(ex.getMessage());
+ }
+ }
+
+ private ToolResult buildObjectInfoResult(S3ObjectInfo info) {
+ Map data = toObjectInfo(info);
+ StringBuilder output = new StringBuilder();
+ output.append("Path: ").append(displayPath(info.key())).append('\n');
+ output.append("Type: ").append(info.directory() ? "Directory" : "File").append('\n');
+ if (info.size() != null) {
+ output.append("Size: ").append(formatSize(info.size())).append('\n');
+ }
+ if (info.contentType() != null && !info.contentType().isBlank()) {
+ output.append("Content-Type: ").append(info.contentType()).append('\n');
+ }
+ if (info.lastModified() != null) {
+ output.append("Modified: ").append(info.lastModified()).append('\n');
+ }
+ if (info.eTag() != null && !info.eTag().isBlank()) {
+ output.append("ETag: ").append(info.eTag()).append('\n');
+ }
+ return ToolResult.success(output.toString().trim(), data);
+ }
+
+ private ToolResult buildTextReadResult(S3ObjectContent content) {
+ String text = decodeText(content.bytes(), content.contentType());
+ boolean truncated = text.length() > getConfig().getMaxInlineTextChars();
+ String visibleText = truncated ? text.substring(0, getConfig().getMaxInlineTextChars()) : text;
+ Map data = baseReadData(content, true);
+ data.put("content", visibleText);
+ data.put("truncated", truncated);
+ if (truncated) {
+ data.put("originalLength", text.length());
+ data.put("attachment", buildAttachment(content, content.bytes()));
+ }
+ String output = truncated
+ ? visibleText + "\n\n[TRUNCATED: " + text.length() + " chars total. Full object attached.]"
+ : visibleText;
+ return ToolResult.success(output, data);
+ }
+
+ private ToolResult buildBinaryReadResult(S3ObjectContent content, long size) {
+ Map data = baseReadData(content, false);
+ Attachment attachment = buildAttachment(content, content.bytes());
+ data.put("attachment", attachment);
+ data.put("filename", attachment.getFilename());
+ StringBuilder output = new StringBuilder();
+ output.append("Read binary object: ").append(content.key()).append('\n');
+ output.append("Size: ").append(formatSize(size)).append('\n');
+ if (content.contentType() != null && !content.contentType().isBlank()) {
+ output.append("Content-Type: ").append(content.contentType());
+ }
+ return ToolResult.success(output.toString().trim(), data);
+ }
+
+ private Map baseReadData(S3ObjectContent content, boolean textFile) {
+ Map data = new LinkedHashMap<>();
+ data.put("bucket", content.bucket());
+ data.put("path", content.key());
+ data.put("size", content.bytes().length);
+ data.put("etag", content.eTag());
+ data.put("content_type", content.contentType());
+ data.put("modified", content.lastModified() != null ? content.lastModified().toString() : null);
+ data.put("is_text", textFile);
+ return data;
+ }
+
+ private Attachment buildAttachment(S3ObjectContent content, byte[] bytes) {
+ String mimeType = content.contentType() != null && !content.contentType().isBlank()
+ ? stripMimeParameters(content.contentType())
+ : S3MimeTypes.detect(content.key());
+ Attachment.Type attachmentType = mimeType.startsWith("image/")
+ ? Attachment.Type.IMAGE
+ : Attachment.Type.DOCUMENT;
+ return Attachment.builder()
+ .type(attachmentType)
+ .data(bytes)
+ .filename(leafName(content.key()))
+ .mimeType(mimeType)
+ .caption("S3 object: " + content.key())
+ .build();
+ }
+
+ private void ensureTargetAbsent(String normalizedTargetPath) {
+ if (client.findObject(normalizedTargetPath) != null || client.directoryExists(normalizedTargetPath)) {
+ throw new IllegalArgumentException("Target already exists: " + normalizedTargetPath);
+ }
+ }
+
+ private S3ObjectInfo requireObject(String normalizedPath) {
+ S3ObjectInfo object = client.findObject(normalizedPath);
+ if (object == null) {
+ if (client.directoryExists(normalizedPath)) {
+ throw new IllegalArgumentException("Not a file: " + normalizedPath);
+ }
+ throw new IllegalArgumentException("File not found: " + normalizedPath);
+ }
+ return object;
+ }
+
+ private void copyDirectory(String sourcePath, String targetPath) {
+ if (!client.directoryExists(sourcePath)) {
+ throw new IllegalArgumentException("Directory not found: " + sourcePath);
+ }
+ List entries = collectDirectoryEntries(sourcePath);
+ for (S3ObjectInfo entry : entries) {
+ String suffix = relativeSuffix(sourcePath, entry.key());
+ String destinationPath = suffix.isBlank() ? targetPath : targetPath + "/" + suffix;
+ if (entry.directory()) {
+ client.createDirectoryMarker(destinationPath);
+ } else {
+ client.copyObject(entry.key(), destinationPath);
+ }
+ }
+ }
+
+ private void deleteDirectory(String path) {
+ List entries = collectDirectoryEntries(path);
+ if (entries.isEmpty()) {
+ throw new IllegalArgumentException("Directory not found: " + path);
+ }
+ for (S3ObjectInfo entry : entries) {
+ if (entry.directory()) {
+ client.deleteDirectoryMarker(entry.key());
+ } else {
+ client.deleteObject(entry.key());
+ }
+ }
+ }
+
+ private List collectDirectoryEntries(String path) {
+ Set seen = new LinkedHashSet<>();
+ List collected = new ArrayList<>();
+ S3ObjectInfo marker = client.findDirectoryMarker(path);
+ if (marker != null && seen.add(marker.key() + "/dir")) {
+ collected.add(marker);
+ }
+ for (S3ObjectInfo entry : client.listTree(path)) {
+ String identity = entry.key() + (entry.directory() ? "/dir" : "/file");
+ if (seen.add(identity)) {
+ collected.add(entry);
+ }
+ }
+ collected.sort((left, right) -> Integer.compare(right.key().length(), left.key().length()));
+ return List.copyOf(collected);
+ }
+
+ private String relativeSuffix(String sourcePath, String entryPath) {
+ if (entryPath.equals(sourcePath)) {
+ return "";
+ }
+ if (entryPath.startsWith(sourcePath + "/")) {
+ return entryPath.substring(sourcePath.length() + 1);
+ }
+ throw new IllegalArgumentException("Entry path " + entryPath + " is not under " + sourcePath);
+ }
+
+ private byte[] appendToExistingContent(String normalizedPath, byte[] payload) {
+ try {
+ S3ObjectInfo existing = requireObject(normalizedPath);
+ long size = existing.size() != null ? existing.size() : 0L;
+ if (size > getConfig().getMaxDownloadBytes()) {
+ throw new IllegalArgumentException("Existing object is too large to append safely: " + normalizedPath);
+ }
+ S3ObjectContent existingContent = client.readObject(normalizedPath);
+ byte[] combined = new byte[existingContent.bytes().length + payload.length];
+ System.arraycopy(existingContent.bytes(), 0, combined, 0, existingContent.bytes().length);
+ System.arraycopy(payload, 0, combined, existingContent.bytes().length, payload.length);
+ return combined;
+ } catch (IllegalArgumentException ex) {
+ if (ex.getMessage() != null && ex.getMessage().startsWith("File not found:")) {
+ return payload;
+ }
+ throw ex;
+ }
+ }
+
+ private byte[] resolveWritePayload(String content, String contentBase64) {
+ if (content != null && !content.isBlank() && contentBase64 != null && !contentBase64.isBlank()) {
+ throw new IllegalArgumentException("Provide either content or content_base64, not both");
+ }
+ if (contentBase64 != null && !contentBase64.isBlank()) {
+ try {
+ return Base64.getDecoder().decode(contentBase64);
+ } catch (IllegalArgumentException ex) {
+ throw new IllegalArgumentException("content_base64 must be valid base64");
+ }
+ }
+ if (content != null) {
+ return content.getBytes(StandardCharsets.UTF_8);
+ }
+ throw new IllegalArgumentException("content or content_base64 is required");
+ }
+
+ private String resolveContentType(String normalizedPath, String content, String contentBase64) {
+ if (contentBase64 != null && !contentBase64.isBlank()) {
+ return S3MimeTypes.detect(normalizedPath);
+ }
+ if (content != null) {
+ return S3MimeTypes.detect(normalizedPath);
+ }
+ return "application/octet-stream";
+ }
+
+ private ToolResult executionFailure(String message) {
+ return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message);
+ }
+
+ private boolean isTextFile(String path, String contentType, byte[] bytes) {
+ String normalizedMimeType = stripMimeParameters(contentType).toLowerCase(Locale.ROOT);
+ if (normalizedMimeType.startsWith("text/") || TEXT_MIME_TYPES.contains(normalizedMimeType)) {
+ return true;
+ }
+ if (normalizedMimeType.startsWith("image/") || normalizedMimeType.startsWith("audio/")
+ || normalizedMimeType.startsWith("video/") || "application/pdf".equals(normalizedMimeType)
+ || "application/zip".equals(normalizedMimeType)
+ || "application/octet-stream".equals(normalizedMimeType)) {
+ return false;
+ }
+ String extension = fileExtension(path);
+ if (TEXT_FILE_EXTENSIONS.contains(extension)) {
+ return true;
+ }
+ int limit = Math.min(bytes.length, 512);
+ for (int index = 0; index < limit; index++) {
+ if (bytes[index] == 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String decodeText(byte[] bytes, String contentType) {
+ Charset charset = StandardCharsets.UTF_8;
+ String lowerMimeType = contentType != null ? contentType.toLowerCase(Locale.ROOT) : "";
+ int charsetIndex = lowerMimeType.indexOf("charset=");
+ if (charsetIndex >= 0) {
+ String charsetName = lowerMimeType.substring(charsetIndex + 8).trim();
+ int separatorIndex = charsetName.indexOf(';');
+ if (separatorIndex >= 0) {
+ charsetName = charsetName.substring(0, separatorIndex).trim();
+ }
+ try {
+ charset = Charset.forName(charsetName.replace('"', ' ').trim());
+ } catch (RuntimeException ignored) {
+ charset = StandardCharsets.UTF_8;
+ }
+ }
+ try {
+ return charset.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPLACE)
+ .onUnmappableCharacter(CodingErrorAction.REPLACE)
+ .decode(ByteBuffer.wrap(bytes))
+ .toString();
+ } catch (CharacterCodingException ignored) {
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+ }
+
+ private Map toObjectInfo(S3ObjectInfo info) {
+ Map data = new LinkedHashMap<>();
+ data.put("bucket", info.bucket());
+ data.put("path", info.key());
+ data.put("name", info.name());
+ data.put("type", info.directory() ? "directory" : "file");
+ data.put("size", info.size());
+ data.put("etag", info.eTag());
+ data.put("content_type", info.contentType());
+ data.put("modified", info.lastModified() != null ? info.lastModified().toString() : null);
+ return data;
+ }
+
+ private String displayPath(String normalizedPath) {
+ return normalizedPath == null || normalizedPath.isBlank() ? "/" : normalizedPath;
+ }
+
+ private String leafName(String path) {
+ String normalized = path;
+ if (normalized.endsWith("/")) {
+ normalized = normalized.substring(0, normalized.length() - 1);
+ }
+ int separatorIndex = normalized.lastIndexOf('/');
+ return separatorIndex >= 0 ? normalized.substring(separatorIndex + 1) : normalized;
+ }
+
+ private String fileExtension(String path) {
+ String currentName = leafName(path).toLowerCase(Locale.ROOT);
+ int dotIndex = currentName.lastIndexOf('.');
+ return dotIndex >= 0 ? currentName.substring(dotIndex + 1) : "";
+ }
+
+ private String stripMimeParameters(String contentType) {
+ if (contentType == null) {
+ return "";
+ }
+ int separatorIndex = contentType.indexOf(';');
+ return separatorIndex >= 0 ? contentType.substring(0, separatorIndex).trim() : contentType.trim();
+ }
+
+ private String formatSize(long bytes) {
+ if (bytes < BYTES_PER_KB) {
+ return bytes + " B";
+ }
+ if (bytes < BYTES_PER_KB * BYTES_PER_KB) {
+ return String.format(Locale.ROOT, "%.1f KB", bytes / (double) BYTES_PER_KB);
+ }
+ if (bytes < BYTES_PER_KB * BYTES_PER_KB * BYTES_PER_KB) {
+ return String.format(Locale.ROOT, "%.1f MB", bytes / (double) (BYTES_PER_KB * BYTES_PER_KB));
+ }
+ return String.format(Locale.ROOT, "%.1f GB", bytes / (double) (BYTES_PER_KB * BYTES_PER_KB * BYTES_PER_KB));
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProvider.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProvider.java
new file mode 100644
index 0000000..2092695
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProvider.java
@@ -0,0 +1,153 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import me.golemcore.plugin.api.extension.model.ToolDefinition;
+import me.golemcore.plugin.api.extension.model.ToolFailureKind;
+import me.golemcore.plugin.api.extension.model.ToolResult;
+import me.golemcore.plugin.api.extension.spi.ToolProvider;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+@Component
+public class S3FilesToolProvider implements ToolProvider {
+
+ private static final String TYPE = "type";
+ private static final String TYPE_OBJECT = "object";
+ private static final String TYPE_STRING = "string";
+ private static final String TYPE_BOOLEAN = "boolean";
+ private static final String PROPERTIES = "properties";
+ private static final String REQUIRED = "required";
+ private static final String PARAM_OPERATION = "operation";
+ private static final String PARAM_PATH = "path";
+ private static final String PARAM_CONTENT = "content";
+ private static final String PARAM_CONTENT_BASE64 = "content_base64";
+ private static final String PARAM_APPEND = "append";
+ private static final String PARAM_TARGET_PATH = "target_path";
+
+ private final S3FilesService service;
+
+ public S3FilesToolProvider(S3FilesService service) {
+ this.service = service;
+ }
+
+ @Override
+ public ToolDefinition getDefinition() {
+ return ToolDefinition.builder()
+ .name("s3_files")
+ .description("Work with S3-compatible object storage through the MinIO Java SDK.")
+ .inputSchema(Map.of(
+ TYPE, TYPE_OBJECT,
+ PROPERTIES, Map.of(
+ PARAM_OPERATION, Map.of(
+ TYPE, TYPE_STRING,
+ "enum", List.of(
+ "list_directory",
+ "read_file",
+ "write_file",
+ "create_directory",
+ "delete",
+ "move",
+ "copy",
+ "file_info")),
+ PARAM_PATH, Map.of(
+ TYPE, TYPE_STRING,
+ "description", "Path relative to the configured S3 root prefix."),
+ PARAM_CONTENT, Map.of(
+ TYPE, TYPE_STRING,
+ "description", "UTF-8 text content for write_file."),
+ PARAM_CONTENT_BASE64, Map.of(
+ TYPE, TYPE_STRING,
+ "description", "Base64-encoded bytes for write_file."),
+ PARAM_APPEND, Map.of(
+ TYPE, TYPE_BOOLEAN,
+ "description", "Append to an existing object when writing."),
+ PARAM_TARGET_PATH, Map.of(
+ TYPE, TYPE_STRING,
+ "description", "Target path for move or copy.")),
+ REQUIRED, List.of(PARAM_OPERATION),
+ "allOf", List.of(
+ requiredWhen("read_file", List.of(PARAM_PATH)),
+ requiredWhen("write_file", List.of(PARAM_PATH)),
+ requiredWhen("create_directory", List.of(PARAM_PATH)),
+ requiredWhen("delete", List.of(PARAM_PATH)),
+ requiredWhen("move", List.of(PARAM_PATH, PARAM_TARGET_PATH)),
+ requiredWhen("copy", List.of(PARAM_PATH, PARAM_TARGET_PATH)),
+ requiredWhen("file_info", List.of(PARAM_PATH)))))
+ .build();
+ }
+
+ @Override
+ public boolean isEnabled() {
+ S3PluginConfig config = service.getConfig();
+ return Boolean.TRUE.equals(config.getEnabled())
+ && hasText(config.getEndpoint())
+ && hasText(config.getAccessKey())
+ && hasText(config.getSecretKey())
+ && hasText(config.getBucket());
+ }
+
+ @Override
+ public CompletableFuture execute(Map parameters) {
+ return CompletableFuture.supplyAsync(() -> executeOperation(parameters));
+ }
+
+ private ToolResult executeOperation(Map parameters) {
+ String operation = readString(parameters.get(PARAM_OPERATION));
+ if (operation == null || operation.isBlank()) {
+ return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "operation is required");
+ }
+
+ return switch (operation) {
+ case "list_directory" -> service.listDirectory(readString(parameters.get(PARAM_PATH)));
+ case "read_file" -> service.readFile(readString(parameters.get(PARAM_PATH)));
+ case "write_file" -> service.writeFile(
+ readString(parameters.get(PARAM_PATH)),
+ readString(parameters.get(PARAM_CONTENT)),
+ readString(parameters.get(PARAM_CONTENT_BASE64)),
+ readBoolean(parameters.get(PARAM_APPEND)));
+ case "create_directory" -> service.createDirectory(readString(parameters.get(PARAM_PATH)));
+ case "delete" -> service.delete(readString(parameters.get(PARAM_PATH)));
+ case "move" -> service.move(
+ readString(parameters.get(PARAM_PATH)),
+ readString(parameters.get(PARAM_TARGET_PATH)));
+ case "copy" -> service.copy(
+ readString(parameters.get(PARAM_PATH)),
+ readString(parameters.get(PARAM_TARGET_PATH)));
+ case "file_info" -> service.fileInfo(readString(parameters.get(PARAM_PATH)));
+ default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED,
+ "Unsupported s3_files operation: " + operation);
+ };
+ }
+
+ private Map requiredWhen(String operation, List requiredFields) {
+ return Map.of(
+ "if", Map.of(
+ PROPERTIES, Map.of(
+ PARAM_OPERATION, Map.of("const", operation))),
+ "then", Map.of(
+ REQUIRED, requiredFields));
+ }
+
+ private String readString(Object value) {
+ if (value instanceof String text) {
+ return text;
+ }
+ return null;
+ }
+
+ private boolean readBoolean(Object value) {
+ if (value instanceof Boolean bool) {
+ return bool;
+ }
+ if (value instanceof String text && !text.isBlank()) {
+ return Boolean.parseBoolean(text);
+ }
+ return false;
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrap.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrap.java
new file mode 100644
index 0000000..ad9f8a9
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrap.java
@@ -0,0 +1,22 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import me.golemcore.plugin.api.extension.spi.PluginBootstrap;
+import me.golemcore.plugin.api.extension.spi.PluginDescriptor;
+
+public class S3PluginBootstrap implements PluginBootstrap {
+
+ @Override
+ public PluginDescriptor descriptor() {
+ return PluginDescriptor.builder()
+ .id("golemcore/s3")
+ .provider("golemcore")
+ .name("s3")
+ .entrypoint(getClass().getName())
+ .build();
+ }
+
+ @Override
+ public Class> configurationClass() {
+ return S3PluginConfiguration.class;
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfig.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfig.java
new file mode 100644
index 0000000..1915916
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfig.java
@@ -0,0 +1,143 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class S3PluginConfig {
+
+ static final String DEFAULT_ENDPOINT = "https://play.min.io";
+ static final String DEFAULT_REGION = "us-east-1";
+ static final int DEFAULT_TIMEOUT_MS = 30_000;
+ static final int DEFAULT_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
+ static final int DEFAULT_MAX_INLINE_TEXT_CHARS = 12_000;
+
+ @Builder.Default
+ private Boolean enabled = false;
+
+ @Builder.Default
+ private String endpoint = DEFAULT_ENDPOINT;
+
+ @Builder.Default
+ private String region = DEFAULT_REGION;
+
+ private String accessKey;
+ private String secretKey;
+
+ @Builder.Default
+ private String bucket = "";
+
+ @Builder.Default
+ private String rootPrefix = "";
+
+ @Builder.Default
+ private Integer timeoutMs = DEFAULT_TIMEOUT_MS;
+
+ @Builder.Default
+ private Boolean allowInsecureTls = false;
+
+ @Builder.Default
+ private Integer maxDownloadBytes = DEFAULT_MAX_DOWNLOAD_BYTES;
+
+ @Builder.Default
+ private Integer maxInlineTextChars = DEFAULT_MAX_INLINE_TEXT_CHARS;
+
+ @Builder.Default
+ private Boolean autoCreateBucket = false;
+
+ @Builder.Default
+ private Boolean allowWrite = false;
+
+ @Builder.Default
+ private Boolean allowDelete = false;
+
+ @Builder.Default
+ private Boolean allowMove = false;
+
+ @Builder.Default
+ private Boolean allowCopy = false;
+
+ public void normalize() {
+ if (enabled == null) {
+ enabled = false;
+ }
+ endpoint = normalizeEndpoint(endpoint, DEFAULT_ENDPOINT);
+ region = normalizeText(region, DEFAULT_REGION);
+ bucket = normalizeText(bucket, "");
+ rootPrefix = normalizePath(rootPrefix);
+ if (timeoutMs == null || timeoutMs <= 0) {
+ timeoutMs = DEFAULT_TIMEOUT_MS;
+ }
+ if (allowInsecureTls == null) {
+ allowInsecureTls = false;
+ }
+ if (maxDownloadBytes == null || maxDownloadBytes <= 0) {
+ maxDownloadBytes = DEFAULT_MAX_DOWNLOAD_BYTES;
+ }
+ if (maxInlineTextChars == null || maxInlineTextChars <= 0) {
+ maxInlineTextChars = DEFAULT_MAX_INLINE_TEXT_CHARS;
+ }
+ if (autoCreateBucket == null) {
+ autoCreateBucket = false;
+ }
+ if (allowWrite == null) {
+ allowWrite = false;
+ }
+ if (allowDelete == null) {
+ allowDelete = false;
+ }
+ if (allowMove == null) {
+ allowMove = false;
+ }
+ if (allowCopy == null) {
+ allowCopy = false;
+ }
+ }
+
+ private String normalizeEndpoint(String value, String defaultValue) {
+ String trimmed = normalizeText(value, defaultValue);
+ while (trimmed.endsWith("/")) {
+ trimmed = trimmed.substring(0, trimmed.length() - 1);
+ }
+ return trimmed.isBlank() ? defaultValue : trimmed;
+ }
+
+ private String normalizePath(String value) {
+ String candidate = normalizeText(value, "").replace('\\', '/');
+ while (candidate.startsWith("/")) {
+ candidate = candidate.substring(1);
+ }
+ while (candidate.endsWith("/")) {
+ candidate = candidate.substring(0, candidate.length() - 1);
+ }
+ String[] rawSegments = candidate.isBlank() ? new String[0] : candidate.split("/");
+ StringBuilder normalized = new StringBuilder();
+ for (String rawSegment : rawSegments) {
+ String segment = rawSegment.trim();
+ if (segment.isEmpty() || ".".equals(segment)) {
+ continue;
+ }
+ if ("..".equals(segment)) {
+ throw new IllegalArgumentException("rootPrefix must stay within the configured bucket root");
+ }
+ if (!normalized.isEmpty()) {
+ normalized.append('/');
+ }
+ normalized.append(segment);
+ }
+ return normalized.toString();
+ }
+
+ private String normalizeText(String value, String defaultValue) {
+ if (value == null) {
+ return defaultValue;
+ }
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? defaultValue : trimmed;
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigService.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigService.java
new file mode 100644
index 0000000..30272a0
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigService.java
@@ -0,0 +1,33 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import me.golemcore.plugin.api.runtime.PluginConfigurationService;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class S3PluginConfigService {
+
+ static final String PLUGIN_ID = "golemcore/s3";
+
+ private final PluginConfigurationService pluginConfigurationService;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ public S3PluginConfig getConfig() {
+ Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID);
+ S3PluginConfig config = raw.isEmpty()
+ ? S3PluginConfig.builder().build()
+ : objectMapper.convertValue(raw, S3PluginConfig.class);
+ config.normalize();
+ return config;
+ }
+
+ @SuppressWarnings("unchecked")
+ public void save(S3PluginConfig config) {
+ config.normalize();
+ pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class));
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfiguration.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfiguration.java
new file mode 100644
index 0000000..850a17d
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfiguration.java
@@ -0,0 +1,9 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration(proxyBeanMethods = false)
+@ComponentScan(basePackageClasses = S3PluginConfiguration.class)
+public class S3PluginConfiguration {
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributor.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributor.java
new file mode 100644
index 0000000..adf10c5
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributor.java
@@ -0,0 +1,253 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import lombok.RequiredArgsConstructor;
+import me.golemcore.plugin.api.extension.spi.PluginActionResult;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsAction;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsCatalogItem;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsContributor;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsField;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsSection;
+import me.golemcore.plugins.golemcore.s3.support.S3MinioClient;
+import me.golemcore.plugins.golemcore.s3.support.S3StorageException;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class S3PluginSettingsContributor implements PluginSettingsContributor {
+
+ private static final String SECTION_KEY = "main";
+ private static final String ACTION_TEST_CONNECTION = "test-connection";
+
+ private final S3PluginConfigService configService;
+ private final S3MinioClient client;
+
+ @Override
+ public String getPluginId() {
+ return S3PluginConfigService.PLUGIN_ID;
+ }
+
+ @Override
+ public List getCatalogItems() {
+ return List.of(PluginSettingsCatalogItem.builder()
+ .pluginId(S3PluginConfigService.PLUGIN_ID)
+ .pluginName("s3")
+ .provider("golemcore")
+ .sectionKey(SECTION_KEY)
+ .title("S3")
+ .description("S3-compatible object storage connection, root sandbox, and file policy.")
+ .blockKey("tools")
+ .blockTitle("Tools")
+ .blockDescription("Tool-specific runtime behavior and integrations")
+ .order(39)
+ .build());
+ }
+
+ @Override
+ public PluginSettingsSection getSection(String sectionKey) {
+ requireSection(sectionKey);
+ S3PluginConfig config = configService.getConfig();
+ Map values = new LinkedHashMap<>();
+ values.put("enabled", Boolean.TRUE.equals(config.getEnabled()));
+ values.put("endpoint", config.getEndpoint());
+ values.put("region", config.getRegion());
+ values.put("accessKey", config.getAccessKey());
+ values.put("secretKey", "");
+ values.put("bucket", config.getBucket());
+ values.put("rootPrefix", config.getRootPrefix().isBlank() ? "/" : "/" + config.getRootPrefix());
+ values.put("timeoutMs", config.getTimeoutMs());
+ values.put("allowInsecureTls", Boolean.TRUE.equals(config.getAllowInsecureTls()));
+ values.put("maxDownloadBytes", config.getMaxDownloadBytes());
+ values.put("maxInlineTextChars", config.getMaxInlineTextChars());
+ values.put("autoCreateBucket", Boolean.TRUE.equals(config.getAutoCreateBucket()));
+ values.put("allowWrite", Boolean.TRUE.equals(config.getAllowWrite()));
+ values.put("allowDelete", Boolean.TRUE.equals(config.getAllowDelete()));
+ values.put("allowMove", Boolean.TRUE.equals(config.getAllowMove()));
+ values.put("allowCopy", Boolean.TRUE.equals(config.getAllowCopy()));
+
+ return PluginSettingsSection.builder()
+ .title("S3")
+ .description(
+ "Configure the S3-compatible object storage connection, root prefix sandbox, and conservative file policy.")
+ .fields(List.of(
+ booleanField("enabled", "Enable S3", "Allow tools to use the S3 integration."),
+ textField("endpoint", "Endpoint", "S3-compatible HTTPS or HTTP endpoint.",
+ "https://play.min.io"),
+ textField("region", "Region", "Default region passed to the MinIO client.", "us-east-1"),
+ textField("accessKey", "Access Key", "Access key ID used for S3 authentication.",
+ "minioadmin"),
+ PluginSettingsField.builder()
+ .key("secretKey")
+ .type("secret")
+ .label("Secret Key")
+ .description("Leave blank to keep the current secret.")
+ .placeholder("Enter secret key")
+ .build(),
+ textField("bucket", "Bucket", "Bucket used as the tool root namespace.", "my-bucket"),
+ textField("rootPrefix", "Root Prefix", "Sandbox all tool paths under this prefix.", "/AI"),
+ numberField("timeoutMs", "Request Timeout (ms)", "Timeout for MinIO S3 requests.",
+ 1000.0, 300000.0, 1000.0),
+ booleanField("allowInsecureTls", "Allow Insecure TLS",
+ "Allow self-signed TLS certificates when connecting to S3."),
+ numberField("maxDownloadBytes", "Max Download Bytes",
+ "Maximum object size that read_file downloads into memory.", 1.0, null, 1024.0),
+ numberField("maxInlineTextChars", "Max Inline Text Chars",
+ "Maximum text characters returned inline before attachment fallback.", 1.0, null, 1.0),
+ booleanField("autoCreateBucket", "Auto Create Bucket",
+ "Create the configured bucket automatically if it does not exist."),
+ booleanField("allowWrite", "Allow Write",
+ "Permit tools to create directories or write objects."),
+ booleanField("allowDelete", "Allow Delete",
+ "Permit tools to delete objects or prefixes."),
+ booleanField("allowMove", "Allow Move",
+ "Permit tools to move objects or prefixes."),
+ booleanField("allowCopy", "Allow Copy",
+ "Permit tools to copy objects or prefixes.")))
+ .values(values)
+ .actions(List.of(PluginSettingsAction.builder()
+ .actionId(ACTION_TEST_CONNECTION)
+ .label("Test Connection")
+ .variant("secondary")
+ .build()))
+ .build();
+ }
+
+ @Override
+ public PluginSettingsSection saveSection(String sectionKey, Map values) {
+ requireSection(sectionKey);
+ S3PluginConfig config = configService.getConfig();
+ config.setEnabled(readBoolean(values, "enabled", false));
+ config.setEndpoint(readString(values, "endpoint", config.getEndpoint()));
+ config.setRegion(readString(values, "region", config.getRegion()));
+ config.setAccessKey(readString(values, "accessKey", config.getAccessKey()));
+ String secretKey = readString(values, "secretKey", null);
+ if (secretKey != null && !secretKey.isBlank()) {
+ config.setSecretKey(secretKey);
+ }
+ config.setBucket(readString(values, "bucket", config.getBucket()));
+ config.setRootPrefix(readString(values, "rootPrefix", config.getRootPrefix()));
+ config.setTimeoutMs(readInteger(values, "timeoutMs", config.getTimeoutMs()));
+ config.setAllowInsecureTls(readBoolean(values, "allowInsecureTls", false));
+ config.setMaxDownloadBytes(readInteger(values, "maxDownloadBytes", config.getMaxDownloadBytes()));
+ config.setMaxInlineTextChars(readInteger(values, "maxInlineTextChars", config.getMaxInlineTextChars()));
+ config.setAutoCreateBucket(readBoolean(values, "autoCreateBucket", false));
+ config.setAllowWrite(readBoolean(values, "allowWrite", false));
+ config.setAllowDelete(readBoolean(values, "allowDelete", false));
+ config.setAllowMove(readBoolean(values, "allowMove", false));
+ config.setAllowCopy(readBoolean(values, "allowCopy", false));
+ configService.save(config);
+ return getSection(sectionKey);
+ }
+
+ @Override
+ public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) {
+ requireSection(sectionKey);
+ if (!ACTION_TEST_CONNECTION.equals(actionId)) {
+ throw new IllegalArgumentException("Unknown S3 action: " + actionId);
+ }
+ return testConnection();
+ }
+
+ private PluginActionResult testConnection() {
+ S3PluginConfig config = configService.getConfig();
+ if (!hasText(config.getEndpoint()) || !hasText(config.getAccessKey()) || !hasText(config.getSecretKey())
+ || !hasText(config.getBucket())) {
+ return PluginActionResult.builder()
+ .status("error")
+ .message("S3 endpoint, access key, secret key, and bucket must be configured.")
+ .build();
+ }
+ try {
+ client.ensureBucketReady();
+ int count = client.listDirectory("").size();
+ return PluginActionResult.builder()
+ .status("ok")
+ .message("Connected to S3 bucket " + config.getBucket() + ". Root returned " + count
+ + " item(s).")
+ .build();
+ } catch (IllegalArgumentException | IllegalStateException | S3StorageException ex) {
+ return PluginActionResult.builder()
+ .status("error")
+ .message("Connection failed: " + ex.getMessage())
+ .build();
+ }
+ }
+
+ private void requireSection(String sectionKey) {
+ if (!SECTION_KEY.equals(sectionKey)) {
+ throw new IllegalArgumentException("Unknown S3 settings section: " + sectionKey);
+ }
+ }
+
+ private PluginSettingsField booleanField(String key, String label, String description) {
+ return PluginSettingsField.builder()
+ .key(key)
+ .type("boolean")
+ .label(label)
+ .description(description)
+ .build();
+ }
+
+ private PluginSettingsField textField(String key, String label, String description, String placeholder) {
+ return PluginSettingsField.builder()
+ .key(key)
+ .type("text")
+ .label(label)
+ .description(description)
+ .placeholder(placeholder)
+ .build();
+ }
+
+ private PluginSettingsField numberField(
+ String key,
+ String label,
+ String description,
+ Double min,
+ Double max,
+ Double step) {
+ return PluginSettingsField.builder()
+ .key(key)
+ .type("number")
+ .label(label)
+ .description(description)
+ .min(min)
+ .max(max)
+ .step(step)
+ .build();
+ }
+
+ private boolean readBoolean(Map values, String key, boolean defaultValue) {
+ Object value = values.get(key);
+ return value instanceof Boolean bool ? bool : defaultValue;
+ }
+
+ private int readInteger(Map values, String key, int defaultValue) {
+ Object value = values.get(key);
+ if (value instanceof Number number) {
+ return number.intValue();
+ }
+ if (value instanceof String text && !text.isBlank()) {
+ try {
+ return Integer.parseInt(text);
+ } catch (NumberFormatException ignored) {
+ return defaultValue;
+ }
+ }
+ return defaultValue;
+ }
+
+ private String readString(Map values, String key, String defaultValue) {
+ Object value = values.get(key);
+ if (value instanceof String text) {
+ return text;
+ }
+ return defaultValue;
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MimeTypes.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MimeTypes.java
new file mode 100644
index 0000000..27a5ced
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MimeTypes.java
@@ -0,0 +1,66 @@
+package me.golemcore.plugins.golemcore.s3.support;
+
+import java.util.Locale;
+
+public final class S3MimeTypes {
+
+ private S3MimeTypes() {
+ }
+
+ public static String detect(String path) {
+ String lowerPath = path == null ? "" : path.toLowerCase(Locale.ROOT);
+ if (lowerPath.endsWith(".txt") || lowerPath.endsWith(".log") || lowerPath.endsWith(".ini")
+ || lowerPath.endsWith(".toml") || lowerPath.endsWith(".properties")) {
+ return "text/plain";
+ }
+ if (lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown")) {
+ return "text/markdown";
+ }
+ if (lowerPath.endsWith(".json")) {
+ return "application/json";
+ }
+ if (lowerPath.endsWith(".xml")) {
+ return "application/xml";
+ }
+ if (lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml")) {
+ return "application/yaml";
+ }
+ if (lowerPath.endsWith(".csv")) {
+ return "text/csv";
+ }
+ if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) {
+ return "text/html";
+ }
+ if (lowerPath.endsWith(".css")) {
+ return "text/css";
+ }
+ if (lowerPath.endsWith(".js")) {
+ return "application/javascript";
+ }
+ if (lowerPath.endsWith(".ts")) {
+ return "text/typescript";
+ }
+ if (lowerPath.endsWith(".svg")) {
+ return "image/svg+xml";
+ }
+ if (lowerPath.endsWith(".png")) {
+ return "image/png";
+ }
+ if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) {
+ return "image/jpeg";
+ }
+ if (lowerPath.endsWith(".gif")) {
+ return "image/gif";
+ }
+ if (lowerPath.endsWith(".webp")) {
+ return "image/webp";
+ }
+ if (lowerPath.endsWith(".pdf")) {
+ return "application/pdf";
+ }
+ if (lowerPath.endsWith(".zip")) {
+ return "application/zip";
+ }
+ return "application/octet-stream";
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClient.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClient.java
new file mode 100644
index 0000000..fbc76e2
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClient.java
@@ -0,0 +1,508 @@
+package me.golemcore.plugins.golemcore.s3.support;
+
+import io.minio.BucketExistsArgs;
+import io.minio.CopyObjectArgs;
+import io.minio.GetObjectArgs;
+import io.minio.GetObjectResponse;
+import io.minio.ListObjectsArgs;
+import io.minio.MakeBucketArgs;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
+import io.minio.Result;
+import io.minio.SourceObject;
+import io.minio.StatObjectArgs;
+import io.minio.StatObjectResponse;
+import io.minio.errors.ErrorResponseException;
+import io.minio.errors.MinioException;
+import io.minio.messages.Item;
+import me.golemcore.plugins.golemcore.s3.S3PluginConfig;
+import me.golemcore.plugins.golemcore.s3.S3PluginConfigService;
+import okhttp3.OkHttpClient;
+import org.springframework.stereotype.Component;
+
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+@Component
+public class S3MinioClient {
+
+ private static final String DIRECTORY_CONTENT_TYPE = "application/x-directory";
+
+ private final S3PluginConfigService configService;
+
+ public S3MinioClient(S3PluginConfigService configService) {
+ this.configService = configService;
+ }
+
+ public void ensureBucketReady() {
+ S3PluginConfig config = getConfig();
+ requireText(config.getEndpoint(), "S3 endpoint is not configured.");
+ requireText(config.getAccessKey(), "S3 access key is not configured.");
+ requireText(config.getSecretKey(), "S3 secret key is not configured.");
+ requireText(config.getBucket(), "S3 bucket is not configured.");
+
+ if (bucketExistsInternal(config.getBucket())) {
+ return;
+ }
+ if (!Boolean.TRUE.equals(config.getAutoCreateBucket())) {
+ throw new IllegalArgumentException("S3 bucket does not exist: " + config.getBucket());
+ }
+ makeBucketInternal(config.getBucket(), config.getRegion());
+ }
+
+ public S3ObjectInfo findObject(String relativePath) {
+ ensureBucketReady();
+ String resolvedKey = resolveKey(relativePath);
+ if (resolvedKey.isBlank()) {
+ return null;
+ }
+ return findObjectByResolvedKey(resolvedKey, false);
+ }
+
+ public S3ObjectInfo findDirectoryMarker(String relativePath) {
+ ensureBucketReady();
+ String normalizedPath = normalizeRelativePath(relativePath);
+ if (normalizedPath.isBlank()) {
+ return null;
+ }
+ String markerKey = resolveDirectoryMarkerKey(normalizedPath);
+ return findObjectByResolvedKey(markerKey, true);
+ }
+
+ public boolean directoryExists(String relativePath) {
+ ensureBucketReady();
+ String normalizedPath = normalizeRelativePath(relativePath);
+ if (normalizedPath.isBlank()) {
+ return true;
+ }
+ if (findDirectoryMarker(normalizedPath) != null) {
+ return true;
+ }
+ return !listDirectory(normalizedPath).isEmpty();
+ }
+
+ public List listDirectory(String relativePath) {
+ ensureBucketReady();
+ String normalizedPath = normalizeRelativePath(relativePath);
+ String prefix = resolveListPrefix(normalizedPath);
+ List rawEntries = listObjectsInternal(getBucket(), prefix, false);
+ List entries = new ArrayList<>();
+ for (S3ObjectInfo rawEntry : rawEntries) {
+ S3ObjectInfo mapped = toRelativeInfo(rawEntry, rawEntry.directory());
+ if (mapped == null) {
+ continue;
+ }
+ if (!normalizedPath.isBlank() && normalizedPath.equals(mapped.key())) {
+ continue;
+ }
+ if (entries.stream().noneMatch(existing -> existing.key().equals(mapped.key()))) {
+ entries.add(mapped);
+ }
+ }
+ return List.copyOf(entries);
+ }
+
+ public List listTree(String relativePath) {
+ ensureBucketReady();
+ String normalizedPath = normalizeRelativePath(relativePath);
+ String prefix = resolveListPrefix(normalizedPath);
+ List rawEntries = listObjectsInternal(getBucket(), prefix, true);
+ List entries = new ArrayList<>();
+ for (S3ObjectInfo rawEntry : rawEntries) {
+ S3ObjectInfo mapped = toRelativeInfo(rawEntry, rawEntry.directory());
+ if (mapped != null) {
+ entries.add(mapped);
+ }
+ }
+ return List.copyOf(entries);
+ }
+
+ public S3ObjectContent readObject(String relativePath) {
+ ensureBucketReady();
+ String resolvedKey = resolveKey(relativePath);
+ S3ObjectInfo metadata = requireObjectByResolvedKey(resolvedKey, false);
+ return getObjectInternal(getBucket(), resolvedKey, metadata);
+ }
+
+ public void writeObject(String relativePath, byte[] bytes, String contentType) {
+ ensureBucketReady();
+ String resolvedKey = resolveKey(relativePath);
+ putObjectInternal(getBucket(), resolvedKey, bytes, contentType);
+ }
+
+ public void createDirectoryMarker(String relativePath) {
+ ensureBucketReady();
+ String resolvedKey = resolveDirectoryMarkerKey(relativePath);
+ putObjectInternal(getBucket(), resolvedKey, new byte[0], DIRECTORY_CONTENT_TYPE);
+ }
+
+ public void copyObject(String sourceRelativePath, String targetRelativePath) {
+ ensureBucketReady();
+ String sourceKey = resolveKey(sourceRelativePath);
+ String targetKey = resolveKey(targetRelativePath);
+ S3ObjectInfo sourceInfo = requireObjectByResolvedKey(sourceKey, false);
+ copyObjectInternal(getBucket(), sourceKey, targetKey, sourceInfo);
+ }
+
+ public void deleteObject(String relativePath) {
+ ensureBucketReady();
+ deleteObjectByResolvedKey(resolveKey(relativePath));
+ }
+
+ public void deleteDirectoryMarker(String relativePath) {
+ ensureBucketReady();
+ deleteObjectByResolvedKey(resolveDirectoryMarkerKey(relativePath));
+ }
+
+ protected boolean bucketExistsInternal(String bucket) {
+ try (MinioClient client = createClient()) {
+ return client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
+ } catch (Exception ex) {
+ throw toStorageException("bucket existence check failed", ex);
+ }
+ }
+
+ protected void makeBucketInternal(String bucket, String region) {
+ try (MinioClient client = createClient()) {
+ client.makeBucket(MakeBucketArgs.builder().bucket(bucket).region(region).build());
+ } catch (Exception ex) {
+ throw toStorageException("bucket creation failed", ex);
+ }
+ }
+
+ protected List listObjectsInternal(String bucket, String prefix, boolean recursive) {
+ try (MinioClient client = createClient()) {
+ Iterable> results = client.listObjects(ListObjectsArgs.builder()
+ .bucket(bucket)
+ .prefix(prefix)
+ .recursive(recursive)
+ .build());
+ List objects = new ArrayList<>();
+ for (Result- result : results) {
+ Item item = result.get();
+ objects.add(new S3ObjectInfo(
+ bucket,
+ item.objectName(),
+ rawName(item.objectName()),
+ item.isDir(),
+ item.isDir() ? null : item.size(),
+ item.isDir() ? null : item.etag(),
+ null,
+ item.lastModified()));
+ }
+ return List.copyOf(objects);
+ } catch (Exception ex) {
+ throw toStorageException("object listing failed", ex);
+ }
+ }
+
+ protected S3ObjectInfo statObjectInternal(String bucket, String key) {
+ try (MinioClient client = createClient()) {
+ StatObjectResponse response = client.statObject(StatObjectArgs.builder()
+ .bucket(bucket)
+ .object(key)
+ .build());
+ return new S3ObjectInfo(
+ bucket,
+ key,
+ rawName(key),
+ false,
+ response.size(),
+ response.etag(),
+ response.contentType(),
+ response.lastModified());
+ } catch (Exception ex) {
+ throw toStorageException("object stat failed", ex);
+ }
+ }
+
+ protected S3ObjectContent getObjectInternal(String bucket, String key, S3ObjectInfo metadata) {
+ try (MinioClient client = createClient();
+ GetObjectResponse response = client.getObject(GetObjectArgs.builder()
+ .bucket(bucket)
+ .object(key)
+ .build())) {
+ return new S3ObjectContent(
+ bucket,
+ key,
+ response.readAllBytes(),
+ metadata.eTag(),
+ metadata.contentType(),
+ metadata.lastModified());
+ } catch (Exception ex) {
+ throw toStorageException("object read failed", ex);
+ }
+ }
+
+ protected void putObjectInternal(String bucket, String key, byte[] bytes, String contentType) {
+ try (MinioClient client = createClient()) {
+ PutObjectArgs.Builder builder = PutObjectArgs.builder()
+ .bucket(bucket)
+ .object(key)
+ .data(bytes, bytes.length);
+ if (contentType != null && !contentType.isBlank()) {
+ builder.contentType(contentType);
+ }
+ client.putObject(builder.build());
+ } catch (Exception ex) {
+ throw toStorageException("object write failed", ex);
+ }
+ }
+
+ protected void copyObjectInternal(String bucket, String sourceKey, String targetKey, S3ObjectInfo sourceInfo) {
+ try (MinioClient client = createClient()) {
+ SourceObject source = new SourceObject(
+ SourceObject.builder().bucket(bucket).object(sourceKey).build(),
+ sourceInfo.size() != null ? sourceInfo.size() : 0L,
+ sourceInfo.eTag());
+ client.copyObject(CopyObjectArgs.builder()
+ .bucket(bucket)
+ .object(targetKey)
+ .source(source)
+ .build());
+ } catch (Exception ex) {
+ throw toStorageException("object copy failed", ex);
+ }
+ }
+
+ protected void removeObjectInternal(String bucket, String key) {
+ try (MinioClient client = createClient()) {
+ client.removeObject(RemoveObjectArgs.builder().bucket(bucket).object(key).build());
+ } catch (Exception ex) {
+ throw toStorageException("object delete failed", ex);
+ }
+ }
+
+ private MinioClient createClient() {
+ S3PluginConfig config = getConfig();
+ OkHttpClient httpClient = buildHttpClient(config);
+ return MinioClient.builder()
+ .endpoint(config.getEndpoint())
+ .region(config.getRegion())
+ .credentials(config.getAccessKey(), config.getSecretKey())
+ .httpClient(httpClient, true)
+ .build();
+ }
+
+ private OkHttpClient buildHttpClient(S3PluginConfig config) {
+ OkHttpClient.Builder builder = new OkHttpClient.Builder()
+ .callTimeout(Duration.ofMillis(config.getTimeoutMs()))
+ .connectTimeout(Duration.ofMillis(config.getTimeoutMs()))
+ .readTimeout(Duration.ofMillis(config.getTimeoutMs()))
+ .writeTimeout(Duration.ofMillis(config.getTimeoutMs()));
+ if (Boolean.TRUE.equals(config.getAllowInsecureTls()) && config.getEndpoint().startsWith("https://")) {
+ try {
+ TrustManager[] trustManagers = new TrustManager[] { new X509TrustManager() {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ } };
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, trustManagers, new SecureRandom());
+ SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
+ X509TrustManager trustManager = (X509TrustManager) trustManagers[0];
+ HostnameVerifier hostnameVerifier = (hostname, session) -> true;
+ builder.sslSocketFactory(sslSocketFactory, trustManager);
+ builder.hostnameVerifier(hostnameVerifier);
+ } catch (GeneralSecurityException ex) {
+ throw new IllegalStateException("Unable to configure insecure TLS for S3", ex);
+ }
+ }
+ return builder.build();
+ }
+
+ private S3ObjectInfo findObjectByResolvedKey(String resolvedKey, boolean directory) {
+ try {
+ S3ObjectInfo rawInfo = statObjectInternal(getBucket(), resolvedKey);
+ return toRelativeInfo(rawInfo, directory);
+ } catch (S3StorageException ex) {
+ if (isNotFound(ex)) {
+ return null;
+ }
+ throw ex;
+ }
+ }
+
+ private S3ObjectInfo requireObjectByResolvedKey(String resolvedKey, boolean directory) {
+ S3ObjectInfo info = findObjectByResolvedKey(resolvedKey, directory);
+ if (info == null) {
+ throw new IllegalArgumentException("Object does not exist: " + stripRootPrefix(resolvedKey, directory));
+ }
+ return info;
+ }
+
+ private void deleteObjectByResolvedKey(String resolvedKey) {
+ try {
+ removeObjectInternal(getBucket(), resolvedKey);
+ } catch (S3StorageException ex) {
+ if (isNotFound(ex)) {
+ throw new IllegalArgumentException(
+ "Object does not exist: " + stripRootPrefix(resolvedKey, resolvedKey.endsWith("/")));
+ }
+ throw ex;
+ }
+ }
+
+ private S3ObjectInfo toRelativeInfo(S3ObjectInfo rawInfo, boolean forceDirectory) {
+ if (rawInfo == null) {
+ return null;
+ }
+ boolean directory = forceDirectory || rawInfo.directory();
+ String relativeKey = stripRootPrefix(rawInfo.key(), directory);
+ if (relativeKey == null) {
+ return null;
+ }
+ if (relativeKey.isBlank() && !directory) {
+ return null;
+ }
+ String name = relativeKey.isBlank() ? "/" : rawName(relativeKey);
+ return new S3ObjectInfo(
+ rawInfo.bucket(),
+ relativeKey,
+ name,
+ directory,
+ directory ? null : rawInfo.size(),
+ rawInfo.eTag(),
+ rawInfo.contentType(),
+ rawInfo.lastModified());
+ }
+
+ private boolean isNotFound(S3StorageException ex) {
+ Throwable cause = ex.getCause();
+ if (cause instanceof ErrorResponseException errorResponseException) {
+ String code = errorResponseException.errorResponse().code();
+ return Objects.equals(code, "NoSuchKey")
+ || Objects.equals(code, "NoSuchBucket")
+ || Objects.equals(code, "NoSuchObject")
+ || Objects.equals(code, "NotFound");
+ }
+ String message = ex.getMessage();
+ return message != null
+ && (message.contains("NoSuchKey")
+ || message.contains("NoSuchBucket")
+ || message.contains("NoSuchObject")
+ || message.contains("NotFound"));
+ }
+
+ private S3StorageException toStorageException(String operation, Exception ex) {
+ if (ex instanceof S3StorageException storageException) {
+ return storageException;
+ }
+ if (ex instanceof ErrorResponseException errorResponseException) {
+ String code = errorResponseException.errorResponse().code();
+ String message = errorResponseException.errorResponse().message();
+ return new S3StorageException("S3 " + operation + " failed: " + code
+ + (message != null && !message.isBlank() ? " - " + message : ""), ex);
+ }
+ if (ex instanceof MinioException) {
+ return new S3StorageException("S3 " + operation + " failed: " + ex.getMessage(), ex);
+ }
+ if (ex instanceof IOException) {
+ return new S3StorageException("S3 transport failed: " + ex.getMessage(), ex);
+ }
+ if (ex instanceof RuntimeException runtimeException) {
+ return new S3StorageException("S3 " + operation + " failed: " + runtimeException.getMessage(), ex);
+ }
+ return new S3StorageException("S3 " + operation + " failed", ex);
+ }
+
+ private String resolveKey(String relativePath) {
+ String normalized = normalizeRelativePath(relativePath);
+ String rootPrefix = getConfig().getRootPrefix();
+ if (rootPrefix.isBlank()) {
+ return normalized;
+ }
+ if (normalized.isBlank()) {
+ return rootPrefix;
+ }
+ return rootPrefix + "/" + normalized;
+ }
+
+ private String resolveDirectoryMarkerKey(String relativePath) {
+ String normalized = normalizeRelativePath(relativePath);
+ String baseKey = resolveKey(normalized);
+ if (baseKey.isBlank()) {
+ return "";
+ }
+ return baseKey.endsWith("/") ? baseKey : baseKey + "/";
+ }
+
+ private String resolveListPrefix(String relativePath) {
+ String normalized = normalizeRelativePath(relativePath);
+ if (normalized.isBlank()) {
+ String rootPrefix = getConfig().getRootPrefix();
+ return rootPrefix.isBlank() ? "" : rootPrefix + "/";
+ }
+ return resolveDirectoryMarkerKey(normalized);
+ }
+
+ private String stripRootPrefix(String rawKey, boolean directory) {
+ String rootPrefix = getConfig().getRootPrefix();
+ String normalizedKey = rawKey;
+ if (directory && normalizedKey.endsWith("/")) {
+ normalizedKey = normalizedKey.substring(0, normalizedKey.length() - 1);
+ }
+ if (rootPrefix.isBlank()) {
+ return normalizedKey;
+ }
+ if (normalizedKey.equals(rootPrefix)) {
+ return "";
+ }
+ String prefix = rootPrefix + "/";
+ if (!normalizedKey.startsWith(prefix)) {
+ return null;
+ }
+ return normalizedKey.substring(prefix.length());
+ }
+
+ private String normalizeRelativePath(String relativePath) {
+ if (relativePath == null) {
+ return "";
+ }
+ return relativePath;
+ }
+
+ private String rawName(String key) {
+ String normalized = key;
+ if (normalized.endsWith("/")) {
+ normalized = normalized.substring(0, normalized.length() - 1);
+ }
+ int separatorIndex = normalized.lastIndexOf('/');
+ return separatorIndex >= 0 ? normalized.substring(separatorIndex + 1) : normalized;
+ }
+
+ private void requireText(String value, String message) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(message);
+ }
+ }
+
+ private String getBucket() {
+ return getConfig().getBucket();
+ }
+
+ private S3PluginConfig getConfig() {
+ return configService.getConfig();
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectContent.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectContent.java
new file mode 100644
index 0000000..c91f44a
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectContent.java
@@ -0,0 +1,5 @@
+package me.golemcore.plugins.golemcore.s3.support;
+
+import java.time.ZonedDateTime;
+
+public record S3ObjectContent(String bucket,String key,byte[]bytes,String eTag,String contentType,ZonedDateTime lastModified){}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectInfo.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectInfo.java
new file mode 100644
index 0000000..8d272d0
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectInfo.java
@@ -0,0 +1,5 @@
+package me.golemcore.plugins.golemcore.s3.support;
+
+import java.time.ZonedDateTime;
+
+public record S3ObjectInfo(String bucket,String key,String name,boolean directory,Long size,String eTag,String contentType,ZonedDateTime lastModified){}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3PathValidator.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3PathValidator.java
new file mode 100644
index 0000000..c979579
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3PathValidator.java
@@ -0,0 +1,51 @@
+package me.golemcore.plugins.golemcore.s3.support;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+public final class S3PathValidator {
+
+ public String normalizeOptionalPath(String path) {
+ if (path == null || path.isBlank()) {
+ return "";
+ }
+ String candidate = path.trim().replace('\\', '/');
+ while (candidate.startsWith("/")) {
+ candidate = candidate.substring(1);
+ }
+ while (candidate.endsWith("/")) {
+ candidate = candidate.substring(0, candidate.length() - 1);
+ }
+
+ Deque segments = new ArrayDeque<>();
+ for (String rawSegment : candidate.split("/")) {
+ String segment = rawSegment.trim();
+ if (segment.isEmpty() || ".".equals(segment)) {
+ continue;
+ }
+ if ("..".equals(segment)) {
+ if (segments.isEmpty()) {
+ throw new IllegalArgumentException("Path must stay within the configured S3 root prefix");
+ }
+ segments.removeLast();
+ continue;
+ }
+ segments.addLast(segment);
+ }
+ return String.join("/", segments);
+ }
+
+ public String normalizeRequiredPath(String path) {
+ String normalized = normalizeOptionalPath(path);
+ if (normalized.isBlank()) {
+ throw new IllegalArgumentException("Path is required");
+ }
+ return normalized;
+ }
+
+ public String parentPath(String normalizedPath) {
+ String normalized = normalizeOptionalPath(normalizedPath);
+ int separatorIndex = normalized.lastIndexOf('/');
+ return separatorIndex >= 0 ? normalized.substring(0, separatorIndex) : "";
+ }
+}
diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3StorageException.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3StorageException.java
new file mode 100644
index 0000000..856ffee
--- /dev/null
+++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3StorageException.java
@@ -0,0 +1,14 @@
+package me.golemcore.plugins.golemcore.s3.support;
+
+public class S3StorageException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public S3StorageException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public S3StorageException(String message) {
+ super(message);
+ }
+}
diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesServiceTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesServiceTest.java
new file mode 100644
index 0000000..e351567
--- /dev/null
+++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesServiceTest.java
@@ -0,0 +1,224 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import me.golemcore.plugin.api.extension.model.Attachment;
+import me.golemcore.plugin.api.extension.model.ToolFailureKind;
+import me.golemcore.plugin.api.extension.model.ToolResult;
+import me.golemcore.plugins.golemcore.s3.support.S3MinioClient;
+import me.golemcore.plugins.golemcore.s3.support.S3ObjectContent;
+import me.golemcore.plugins.golemcore.s3.support.S3ObjectInfo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class S3FilesServiceTest {
+
+ private S3PluginConfigService configService;
+ private S3MinioClient client;
+ private S3FilesService service;
+
+ @BeforeEach
+ void setUp() {
+ configService = mock(S3PluginConfigService.class);
+ client = mock(S3MinioClient.class);
+ when(configService.getConfig()).thenReturn(defaultConfig());
+ service = new S3FilesService(client, configService);
+ }
+
+ @Test
+ void shouldListRootDirectory() {
+ when(client.listDirectory("")).thenReturn(List.of(
+ new S3ObjectInfo("files", "docs", "docs", true, null, null, null, null),
+ new S3ObjectInfo("files", "notes.md", "notes.md", false, 123L, "etag", "text/markdown", null)));
+
+ ToolResult result = service.listDirectory("");
+
+ assertTrue(result.isSuccess());
+ assertTrue(result.getOutput().contains("Directory: /"));
+ assertTrue(result.getOutput().contains("[DIR] docs/"));
+ assertTrue(result.getOutput().contains("[FILE] notes.md"));
+ }
+
+ @Test
+ void shouldReturnInlineTextWhenReadFileIsText() {
+ when(client.findObject("docs/readme.md")).thenReturn(file("docs/readme.md", 5L, "text/plain"));
+ when(client.readObject("docs/readme.md")).thenReturn(new S3ObjectContent(
+ "files",
+ "docs/readme.md",
+ "hello".getBytes(),
+ "etag",
+ "text/plain; charset=utf-8",
+ ZonedDateTime.parse("2026-04-08T12:00:00Z")));
+
+ ToolResult result = service.readFile("docs/readme.md");
+
+ assertTrue(result.isSuccess());
+ assertEquals("hello", result.getOutput());
+ Map, ?> data = assertInstanceOf(Map.class, result.getData());
+ assertEquals("docs/readme.md", data.get("path"));
+ assertEquals(true, data.get("is_text"));
+ assertEquals(false, data.get("truncated"));
+ }
+
+ @Test
+ void shouldAttachBinaryObjectsOnRead() {
+ when(client.findObject("docs/archive.zip")).thenReturn(file("docs/archive.zip", 3L, "application/zip"));
+ when(client.readObject("docs/archive.zip")).thenReturn(new S3ObjectContent(
+ "files",
+ "docs/archive.zip",
+ new byte[] { 1, 2, 3 },
+ "etag",
+ "application/zip",
+ null));
+
+ ToolResult result = service.readFile("docs/archive.zip");
+
+ assertTrue(result.isSuccess());
+ Map, ?> data = assertInstanceOf(Map.class, result.getData());
+ Attachment attachment = assertInstanceOf(Attachment.class, data.get("attachment"));
+ assertEquals("archive.zip", attachment.getFilename());
+ assertEquals("application/zip", attachment.getMimeType());
+ }
+
+ @Test
+ void shouldWriteBase64BinaryContent() {
+ ToolResult result = service.writeFile("docs/file.bin", null, "AQID", false);
+
+ assertTrue(result.isSuccess());
+ verify(client).writeObject("docs/file.bin", new byte[] { 1, 2, 3 }, "application/octet-stream");
+ }
+
+ @Test
+ void shouldAppendExistingObjectContent() {
+ when(client.findObject("docs/file.txt")).thenReturn(file("docs/file.txt", 3L, "text/plain"));
+ when(client.readObject("docs/file.txt")).thenReturn(new S3ObjectContent(
+ "files",
+ "docs/file.txt",
+ "abc".getBytes(),
+ "etag",
+ "text/plain",
+ null));
+
+ ToolResult result = service.writeFile("docs/file.txt", "def", null, true);
+
+ assertTrue(result.isSuccess());
+ verify(client).writeObject("docs/file.txt", "abcdef".getBytes(), "text/plain");
+ }
+
+ @Test
+ void shouldCreateDirectoryMarkers() {
+ when(client.findObject("docs/archive")).thenReturn(null);
+ when(client.directoryExists("docs/archive")).thenReturn(false);
+
+ ToolResult result = service.createDirectory("docs/archive");
+
+ assertTrue(result.isSuccess());
+ verify(client).createDirectoryMarker("docs/archive");
+ }
+
+ @Test
+ void shouldDeleteObjects() {
+ when(client.findObject("docs/file.txt")).thenReturn(file("docs/file.txt", 1L, "text/plain"));
+
+ ToolResult result = service.delete("docs/file.txt");
+
+ assertTrue(result.isSuccess());
+ verify(client).deleteObject("docs/file.txt");
+ }
+
+ @Test
+ void shouldDeleteDirectoriesRecursively() {
+ when(client.findObject("docs/archive")).thenReturn(null);
+ when(client.directoryExists("docs/archive")).thenReturn(true);
+ when(client.findDirectoryMarker("docs/archive")).thenReturn(new S3ObjectInfo(
+ "files",
+ "docs/archive",
+ "archive",
+ true,
+ null,
+ null,
+ null,
+ null));
+ when(client.listTree("docs/archive")).thenReturn(List.of(
+ new S3ObjectInfo("files", "docs/archive/file.txt", "file.txt", false, 1L, "etag", "text/plain", null)));
+
+ ToolResult result = service.delete("docs/archive");
+
+ assertTrue(result.isSuccess());
+ verify(client).deleteObject("docs/archive/file.txt");
+ verify(client).deleteDirectoryMarker("docs/archive");
+ }
+
+ @Test
+ void shouldMoveObjects() {
+ when(client.findObject("docs/file.txt")).thenReturn(file("docs/file.txt", 1L, "text/plain"));
+ when(client.findObject("archive/file.txt")).thenReturn(null);
+ when(client.directoryExists("archive/file.txt")).thenReturn(false);
+
+ ToolResult result = service.move("docs/file.txt", "archive/file.txt");
+
+ assertTrue(result.isSuccess());
+ verify(client).copyObject("docs/file.txt", "archive/file.txt");
+ verify(client).deleteObject("docs/file.txt");
+ }
+
+ @Test
+ void shouldCopyObjects() {
+ when(client.findObject("docs/file.txt")).thenReturn(file("docs/file.txt", 1L, "text/plain"));
+ when(client.findObject("archive/file.txt")).thenReturn(null);
+ when(client.directoryExists("archive/file.txt")).thenReturn(false);
+
+ ToolResult result = service.copy("docs/file.txt", "archive/file.txt");
+
+ assertTrue(result.isSuccess());
+ verify(client).copyObject("docs/file.txt", "archive/file.txt");
+ }
+
+ @Test
+ void shouldDenyWriteWhenDisabled() {
+ when(configService.getConfig()).thenReturn(S3PluginConfig.builder()
+ .enabled(true)
+ .endpoint("https://storage.example.com")
+ .accessKey("minio")
+ .secretKey("secret")
+ .bucket("files")
+ .allowWrite(false)
+ .build());
+
+ ToolResult result = service.writeFile("docs/file.txt", "hello", null, false);
+
+ assertFalse(result.isSuccess());
+ assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind());
+ }
+
+ private S3ObjectInfo file(String key, Long size, String contentType) {
+ return new S3ObjectInfo("files", key, key.substring(key.lastIndexOf('/') + 1), false, size, "etag", contentType,
+ ZonedDateTime.parse("2026-04-08T12:00:00Z"));
+ }
+
+ private S3PluginConfig defaultConfig() {
+ return S3PluginConfig.builder()
+ .enabled(true)
+ .endpoint("https://storage.example.com")
+ .accessKey("minio")
+ .secretKey("secret")
+ .bucket("files")
+ .allowWrite(true)
+ .allowDelete(true)
+ .allowMove(true)
+ .allowCopy(true)
+ .maxDownloadBytes(1024)
+ .maxInlineTextChars(100)
+ .build();
+ }
+}
diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProviderTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProviderTest.java
new file mode 100644
index 0000000..f31b6d8
--- /dev/null
+++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProviderTest.java
@@ -0,0 +1,111 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import me.golemcore.plugin.api.extension.model.ToolDefinition;
+import me.golemcore.plugin.api.extension.model.ToolFailureKind;
+import me.golemcore.plugin.api.extension.model.ToolResult;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class S3FilesToolProviderTest {
+
+ private S3FilesService service;
+ private S3FilesToolProvider provider;
+
+ @BeforeEach
+ void setUp() {
+ service = mock(S3FilesService.class);
+ provider = new S3FilesToolProvider(service);
+ }
+
+ @Test
+ void shouldExposeAllSupportedOperations() {
+ ToolDefinition definition = provider.getDefinition();
+
+ Map schema = definition.getInputSchema();
+ Map, ?> properties = (Map, ?>) schema.get("properties");
+ Map, ?> operation = (Map, ?>) properties.get("operation");
+
+ assertEquals("s3_files", definition.getName());
+ assertEquals(List.of(
+ "list_directory",
+ "read_file",
+ "write_file",
+ "create_directory",
+ "delete",
+ "move",
+ "copy",
+ "file_info"), operation.get("enum"));
+ }
+
+ @Test
+ void shouldBeEnabledWhenConfigHasCredentialsAndBucket() {
+ when(service.getConfig()).thenReturn(S3PluginConfig.builder()
+ .enabled(true)
+ .endpoint("https://storage.example.com")
+ .accessKey("minio")
+ .secretKey("secret")
+ .bucket("files")
+ .build());
+
+ boolean enabled = provider.isEnabled();
+
+ assertTrue(enabled);
+ }
+
+ @Test
+ void shouldRejectMissingOperation() {
+ ToolResult result = provider.execute(Map.of()).join();
+
+ assertFalse(result.isSuccess());
+ assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind());
+ assertTrue(result.getError().contains("operation is required"));
+ }
+
+ @Test
+ void shouldDispatchWriteToService() {
+ when(service.writeFile("docs/file.bin", null, "AQID", true))
+ .thenReturn(ToolResult.success("written"));
+
+ ToolResult result = provider.execute(Map.of(
+ "operation", "write_file",
+ "path", "docs/file.bin",
+ "content_base64", "AQID",
+ "append", true)).join();
+
+ assertTrue(result.isSuccess());
+ verify(service).writeFile("docs/file.bin", null, "AQID", true);
+ }
+
+ @Test
+ void shouldDispatchCopyToService() {
+ when(service.copy("docs/a.txt", "archive/a.txt"))
+ .thenReturn(ToolResult.success("copied"));
+
+ ToolResult result = provider.execute(Map.of(
+ "operation", "copy",
+ "path", "docs/a.txt",
+ "target_path", "archive/a.txt")).join();
+
+ assertTrue(result.isSuccess());
+ verify(service).copy("docs/a.txt", "archive/a.txt");
+ }
+
+ @Test
+ void shouldRejectUnsupportedOperation() {
+ ToolResult result = provider.execute(Map.of("operation", "unknown")).join();
+
+ assertFalse(result.isSuccess());
+ assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind());
+ assertTrue(result.getError().contains("Unsupported"));
+ }
+}
diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrapTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrapTest.java
new file mode 100644
index 0000000..cda43b1
--- /dev/null
+++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrapTest.java
@@ -0,0 +1,21 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import me.golemcore.plugin.api.extension.spi.PluginDescriptor;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class S3PluginBootstrapTest {
+
+ @Test
+ void shouldDescribeS3Plugin() {
+ S3PluginBootstrap bootstrap = new S3PluginBootstrap();
+
+ PluginDescriptor descriptor = bootstrap.descriptor();
+
+ assertEquals("golemcore/s3", descriptor.getId());
+ assertEquals("golemcore", descriptor.getProvider());
+ assertEquals("s3", descriptor.getName());
+ assertEquals(S3PluginConfiguration.class, bootstrap.configurationClass());
+ }
+}
diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigTest.java
new file mode 100644
index 0000000..262359b
--- /dev/null
+++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigTest.java
@@ -0,0 +1,46 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+class S3PluginConfigTest {
+
+ @Test
+ void shouldNormalizeSafeDefaultsAndRootPrefix() {
+ S3PluginConfig config = S3PluginConfig.builder()
+ .enabled(null)
+ .endpoint(" https://storage.example.com/ ")
+ .region(" ")
+ .bucket(" files ")
+ .rootPrefix(" /AI/Uploads/ ")
+ .timeoutMs(0)
+ .allowInsecureTls(null)
+ .maxDownloadBytes(0)
+ .maxInlineTextChars(0)
+ .autoCreateBucket(null)
+ .allowWrite(null)
+ .allowDelete(null)
+ .allowMove(null)
+ .allowCopy(null)
+ .build();
+
+ config.normalize();
+
+ assertFalse(config.getEnabled());
+ assertEquals("https://storage.example.com", config.getEndpoint());
+ assertEquals("us-east-1", config.getRegion());
+ assertEquals("files", config.getBucket());
+ assertEquals("AI/Uploads", config.getRootPrefix());
+ assertEquals(30_000, config.getTimeoutMs());
+ assertFalse(config.getAllowInsecureTls());
+ assertEquals(50 * 1024 * 1024, config.getMaxDownloadBytes());
+ assertEquals(12_000, config.getMaxInlineTextChars());
+ assertFalse(config.getAutoCreateBucket());
+ assertFalse(config.getAllowWrite());
+ assertFalse(config.getAllowDelete());
+ assertFalse(config.getAllowMove());
+ assertFalse(config.getAllowCopy());
+ }
+}
diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributorTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributorTest.java
new file mode 100644
index 0000000..8d19e45
--- /dev/null
+++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributorTest.java
@@ -0,0 +1,161 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import me.golemcore.plugin.api.extension.spi.PluginActionResult;
+import me.golemcore.plugin.api.extension.spi.PluginSettingsSection;
+import me.golemcore.plugins.golemcore.s3.support.S3MinioClient;
+import me.golemcore.plugins.golemcore.s3.support.S3StorageException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class S3PluginSettingsContributorTest {
+
+ private S3PluginConfigService configService;
+ private S3MinioClient client;
+ private S3PluginSettingsContributor contributor;
+ private S3PluginConfig config;
+
+ @BeforeEach
+ void setUp() {
+ configService = mock(S3PluginConfigService.class);
+ client = mock(S3MinioClient.class);
+ contributor = new S3PluginSettingsContributor(configService, client);
+ config = S3PluginConfig.builder().build();
+ config.normalize();
+ when(configService.getConfig()).thenReturn(config);
+ }
+
+ @Test
+ void shouldExposeSectionWithBlankSecretAndSafeDefaults() {
+ PluginSettingsSection section = contributor.getSection("main");
+
+ assertEquals(false, section.getValues().get("enabled"));
+ assertEquals("https://play.min.io", section.getValues().get("endpoint"));
+ assertEquals("us-east-1", section.getValues().get("region"));
+ assertEquals("", section.getValues().get("secretKey"));
+ assertEquals("/", section.getValues().get("rootPrefix"));
+ assertEquals(30_000, section.getValues().get("timeoutMs"));
+ assertEquals(false, section.getValues().get("allowInsecureTls"));
+ assertEquals(50 * 1024 * 1024, section.getValues().get("maxDownloadBytes"));
+ assertEquals(12_000, section.getValues().get("maxInlineTextChars"));
+ assertFalse((Boolean) section.getValues().get("autoCreateBucket"));
+ assertFalse((Boolean) section.getValues().get("allowWrite"));
+ assertFalse((Boolean) section.getValues().get("allowDelete"));
+ assertFalse((Boolean) section.getValues().get("allowMove"));
+ assertFalse((Boolean) section.getValues().get("allowCopy"));
+ }
+
+ @Test
+ void shouldRoundTripSavedPolicyFlagsThroughGetSection() {
+ S3PluginConfig initialConfig = S3PluginConfig.builder()
+ .secretKey("existing-secret")
+ .build();
+ initialConfig.normalize();
+ S3PluginConfig persistedConfig = S3PluginConfig.builder()
+ .enabled(true)
+ .endpoint("https://storage.example.com")
+ .region("eu-west-1")
+ .accessKey("minio")
+ .secretKey("existing-secret")
+ .bucket("files")
+ .rootPrefix("AI")
+ .timeoutMs(45_000)
+ .allowInsecureTls(true)
+ .maxDownloadBytes(4096)
+ .maxInlineTextChars(2048)
+ .autoCreateBucket(true)
+ .allowWrite(true)
+ .allowDelete(false)
+ .allowMove(true)
+ .allowCopy(false)
+ .build();
+ persistedConfig.normalize();
+ when(configService.getConfig()).thenReturn(initialConfig, persistedConfig);
+
+ Map values = new LinkedHashMap<>();
+ values.put("enabled", true);
+ values.put("endpoint", "https://storage.example.com");
+ values.put("region", "eu-west-1");
+ values.put("accessKey", "minio");
+ values.put("secretKey", "");
+ values.put("bucket", "files");
+ values.put("rootPrefix", "/AI");
+ values.put("timeoutMs", 45_000);
+ values.put("allowInsecureTls", true);
+ values.put("maxDownloadBytes", 4096);
+ values.put("maxInlineTextChars", 2048);
+ values.put("autoCreateBucket", true);
+ values.put("allowWrite", true);
+ values.put("allowDelete", false);
+ values.put("allowMove", true);
+ values.put("allowCopy", false);
+
+ PluginSettingsSection section = contributor.saveSection("main", values);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(S3PluginConfig.class);
+ verify(configService).save(captor.capture());
+ S3PluginConfig saved = captor.getValue();
+ assertEquals("existing-secret", saved.getSecretKey());
+ assertTrue(saved.getAutoCreateBucket());
+ assertTrue(saved.getAllowWrite());
+ assertFalse(saved.getAllowDelete());
+ assertTrue(saved.getAllowMove());
+ assertFalse(saved.getAllowCopy());
+
+ assertEquals("", section.getValues().get("secretKey"));
+ assertEquals("/AI", section.getValues().get("rootPrefix"));
+ assertTrue((Boolean) section.getValues().get("autoCreateBucket"));
+ assertTrue((Boolean) section.getValues().get("allowWrite"));
+ assertFalse((Boolean) section.getValues().get("allowDelete"));
+ assertTrue((Boolean) section.getValues().get("allowMove"));
+ assertFalse((Boolean) section.getValues().get("allowCopy"));
+ }
+
+ @Test
+ void shouldReturnOkWhenConnectionTestSucceeds() {
+ config.setEndpoint("https://storage.example.com");
+ config.setAccessKey("minio");
+ config.setSecretKey("secret");
+ config.setBucket("files");
+ when(client.listDirectory("")).thenReturn(java.util.List.of());
+
+ PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of());
+
+ assertEquals("ok", result.getStatus());
+ assertEquals("Connected to S3 bucket files. Root returned 0 item(s).", result.getMessage());
+ }
+
+ @Test
+ void shouldReturnErrorWhenRequiredSettingsAreMissing() {
+ PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of());
+
+ assertEquals("error", result.getStatus());
+ assertEquals("S3 endpoint, access key, secret key, and bucket must be configured.", result.getMessage());
+ }
+
+ @Test
+ void shouldReturnErrorWhenConnectionTestFails() {
+ config.setEndpoint("https://storage.example.com");
+ config.setAccessKey("minio");
+ config.setSecretKey("secret");
+ config.setBucket("files");
+ doThrow(new S3StorageException("S3 object listing failed: AccessDenied"))
+ .when(client).ensureBucketReady();
+
+ PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of());
+
+ assertEquals("error", result.getStatus());
+ assertEquals("Connection failed: S3 object listing failed: AccessDenied", result.getMessage());
+ }
+}
diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3SpringWiringTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3SpringWiringTest.java
new file mode 100644
index 0000000..194795b
--- /dev/null
+++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3SpringWiringTest.java
@@ -0,0 +1,36 @@
+package me.golemcore.plugins.golemcore.s3;
+
+import me.golemcore.plugin.api.runtime.PluginConfigurationService;
+import me.golemcore.plugins.golemcore.s3.support.S3MinioClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+
+class S3SpringWiringTest {
+
+ @Test
+ void shouldCreateS3BeansWithoutDefaultConstructor() {
+ try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
+ context.register(S3PluginConfiguration.class, TestConfig.class);
+ context.refresh();
+
+ assertNotNull(context.getBean(S3MinioClient.class));
+ assertNotNull(context.getBean(S3PluginSettingsContributor.class));
+ assertNotNull(context.getBean(S3FilesToolProvider.class));
+ }
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @SuppressWarnings("PMD.TestClassWithoutTestCases")
+ static class TestConfig {
+
+ @Bean
+ PluginConfigurationService pluginConfigurationService() {
+ return mock(PluginConfigurationService.class);
+ }
+ }
+}
diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClientTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClientTest.java
new file mode 100644
index 0000000..b5c45b1
--- /dev/null
+++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClientTest.java
@@ -0,0 +1,217 @@
+package me.golemcore.plugins.golemcore.s3.support;
+
+import me.golemcore.plugins.golemcore.s3.S3PluginConfig;
+import me.golemcore.plugins.golemcore.s3.S3PluginConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class S3MinioClientTest {
+
+ private S3PluginConfigService configService;
+ private StubS3MinioClient client;
+
+ @BeforeEach
+ void setUp() {
+ configService = mock(S3PluginConfigService.class);
+ when(configService.getConfig()).thenReturn(S3PluginConfig.builder()
+ .enabled(true)
+ .endpoint("https://storage.example.com")
+ .region("us-east-1")
+ .accessKey("minio")
+ .secretKey("secret")
+ .bucket("files")
+ .rootPrefix("AI")
+ .build());
+ client = new StubS3MinioClient(configService);
+ }
+
+ @Test
+ void shouldEnsureBucketExistsWithoutCreatingWhenPresent() {
+ client.bucketExists = true;
+
+ client.ensureBucketReady();
+
+ assertFalse(client.makeBucketCalled);
+ }
+
+ @Test
+ void shouldCreateBucketWhenMissingAndAutoCreateEnabled() {
+ when(configService.getConfig()).thenReturn(S3PluginConfig.builder()
+ .enabled(true)
+ .endpoint("https://storage.example.com")
+ .region("eu-west-1")
+ .accessKey("minio")
+ .secretKey("secret")
+ .bucket("files")
+ .rootPrefix("AI")
+ .autoCreateBucket(true)
+ .build());
+ client.bucketExists = false;
+
+ client.ensureBucketReady();
+
+ assertTrue(client.makeBucketCalled);
+ assertEquals("files", client.lastBucket);
+ assertEquals("eu-west-1", client.lastRegion);
+ }
+
+ @Test
+ void shouldListDirectoryAndStripRootPrefix() {
+ client.bucketExists = true;
+ client.listObjectsResult = List.of(
+ new S3ObjectInfo("files", "AI/docs/readme.md", "readme.md", false, 5L, "etag", "text/plain", null),
+ new S3ObjectInfo("files", "AI/docs/archive/", "archive", true, null, null, null, null));
+
+ List entries = client.listDirectory("docs");
+
+ assertEquals(2, entries.size());
+ assertEquals("docs/readme.md", entries.get(0).key());
+ assertEquals("docs/archive", entries.get(1).key());
+ }
+
+ @Test
+ void shouldReadObjectContent() {
+ client.bucketExists = true;
+ client.statObject = new S3ObjectInfo("files", "AI/docs/readme.md", "readme.md", false, 5L, "etag",
+ "text/plain", ZonedDateTime.parse("2026-04-08T12:00:00Z"));
+ client.objectContent = new S3ObjectContent("files", "AI/docs/readme.md", "hello".getBytes(), "etag",
+ "text/plain", ZonedDateTime.parse("2026-04-08T12:00:00Z"));
+
+ S3ObjectContent content = client.readObject("docs/readme.md");
+
+ assertEquals("AI/docs/readme.md", client.lastKey);
+ assertEquals("hello", new String(content.bytes()));
+ }
+
+ @Test
+ void shouldWriteObjectsThroughResolvedRootPrefix() {
+ client.bucketExists = true;
+
+ client.writeObject("docs/readme.md", "hello".getBytes(), "text/plain");
+
+ assertEquals("AI/docs/readme.md", client.lastKey);
+ assertEquals("text/plain", client.lastContentType);
+ }
+
+ @Test
+ void shouldCopyAndDeleteObjectsThroughResolvedRootPrefix() {
+ client.bucketExists = true;
+ client.statObject = new S3ObjectInfo("files", "AI/docs/readme.md", "readme.md", false, 5L, "etag",
+ "text/plain", null);
+
+ client.copyObject("docs/readme.md", "archive/readme.md");
+ client.deleteObject("docs/readme.md");
+
+ assertEquals("AI/archive/readme.md", client.lastTargetKey);
+ assertEquals("AI/docs/readme.md", client.lastDeletedKey);
+ }
+
+ @Test
+ void shouldExposeDirectoryMarkersSeparately() {
+ client.bucketExists = true;
+ client.directoryMarker = new S3ObjectInfo("files", "AI/docs/", "docs", true, null, null, null, null);
+
+ S3ObjectInfo marker = client.findDirectoryMarker("docs");
+
+ assertNotNull(marker);
+ assertTrue(marker.directory());
+ assertEquals("docs", marker.key());
+ }
+
+ @Test
+ void shouldReturnNullWhenObjectIsMissing() {
+ client.bucketExists = true;
+ client.notFound = true;
+
+ S3ObjectInfo result = client.findObject("docs/missing.txt");
+
+ assertEquals(null, result);
+ }
+
+ private static final class StubS3MinioClient extends S3MinioClient {
+
+ private boolean bucketExists;
+ private boolean makeBucketCalled;
+ private boolean notFound;
+ private String lastBucket;
+ private String lastRegion;
+ private String lastKey;
+ private String lastTargetKey;
+ private String lastDeletedKey;
+ private String lastContentType;
+ private List listObjectsResult = List.of();
+ private S3ObjectInfo statObject;
+ private S3ObjectInfo directoryMarker;
+ private S3ObjectContent objectContent;
+
+ private StubS3MinioClient(S3PluginConfigService configService) {
+ super(configService);
+ }
+
+ @Override
+ protected boolean bucketExistsInternal(String bucket) {
+ lastBucket = bucket;
+ return bucketExists;
+ }
+
+ @Override
+ protected void makeBucketInternal(String bucket, String region) {
+ makeBucketCalled = true;
+ lastBucket = bucket;
+ lastRegion = region;
+ }
+
+ @Override
+ protected List listObjectsInternal(String bucket, String prefix, boolean recursive) {
+ return listObjectsResult;
+ }
+
+ @Override
+ protected S3ObjectInfo statObjectInternal(String bucket, String key) {
+ lastKey = key;
+ if (notFound) {
+ throw new S3StorageException("S3 object stat failed: NotFound");
+ }
+ if (directoryMarker != null && key.endsWith("/")) {
+ return directoryMarker;
+ }
+ return statObject;
+ }
+
+ @Override
+ protected S3ObjectContent getObjectInternal(String bucket, String key, S3ObjectInfo metadata) {
+ lastKey = key;
+ return objectContent;
+ }
+
+ @Override
+ protected void putObjectInternal(String bucket, String key, byte[] bytes, String contentType) {
+ lastBucket = bucket;
+ lastKey = key;
+ lastContentType = contentType;
+ }
+
+ @Override
+ protected void copyObjectInternal(String bucket, String sourceKey, String targetKey, S3ObjectInfo sourceInfo) {
+ lastBucket = bucket;
+ lastKey = sourceKey;
+ lastTargetKey = targetKey;
+ }
+
+ @Override
+ protected void removeObjectInternal(String bucket, String key) {
+ lastBucket = bucket;
+ lastDeletedKey = key;
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 272ad83..b7550a8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -77,6 +77,7 @@
golemcore/browserless
golemcore/obsidian
golemcore/notion
+ golemcore/s3
golemcore/tavily-search
golemcore/firecrawl
golemcore/perplexity-sonar
diff --git a/registry/golemcore/s3/index.yaml b/registry/golemcore/s3/index.yaml
new file mode 100644
index 0000000..f882ce5
--- /dev/null
+++ b/registry/golemcore/s3/index.yaml
@@ -0,0 +1,7 @@
+id: golemcore/s3
+owner: golemcore
+name: s3
+latest: 1.0.0
+versions:
+ - 1.0.0
+source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/s3"
diff --git a/registry/golemcore/s3/versions/1.0.0.yaml b/registry/golemcore/s3/versions/1.0.0.yaml
new file mode 100644
index 0000000..5074cdf
--- /dev/null
+++ b/registry/golemcore/s3/versions/1.0.0.yaml
@@ -0,0 +1,12 @@
+id: golemcore/s3
+version: 1.0.0
+pluginApiVersion: 1
+engineVersion: ">=0.0.0 <1.0.0"
+artifactUrl: "dist/golemcore/s3/1.0.0/golemcore-s3-plugin-1.0.0.jar"
+publishedAt: "2026-04-08T15:05:00Z"
+sourceCommit: "0172b8e13a2b49a7bd7bdb3a0bf48e76371ab458"
+entrypoint: me.golemcore.plugins.golemcore.s3.S3PluginBootstrap
+sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/s3"
+license: "Apache-2.0"
+maintainers:
+ - alexk-dev