From 37a6976dbaf4ccb56729759ef1a7e16e6146ad7c Mon Sep 17 00:00:00 2001 From: Rockford lhotka Date: Tue, 16 Jun 2026 15:42:39 -0500 Subject: [PATCH] AgentReply.Attachments: carry binary payloads end-to-end (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real slice of the ImageAttachment capability (#415). AgentReply gains an optional Attachments list carrying out-of-band binaries as shared-PVC path references (mime + path + optional fileName), reusing the MCP-attachment scheme — bytes never ride the bus. Producing side (user-message path): - AgentAttachment record + AgentReply.Attachments (camelCase STJ, no converter). - ReplyAttachmentBuffer (host singleton, mirrors SessionClientCapabilityStore): session-keyed Add/Drain/Clear. - AttachmentReplyTools exposes an attach_image LLM tool that containment-checks a model-named file under IAttachmentStorage.BasePath, verifies existence, infers mime from extension, and stages it. No bytes read agent-side ("Nothing trusts the LLM"). - IAttachmentStorage registered as a singleton; UserMessageHandler adds the tool to the Main profile and drains the buffer onto final replies only. Rendering side: - Blazor: AttachmentList component renders image mimes as native OUTSIDE the markdown sanitizer (which strips /data: URLs), served from a minimal /attachments endpoint backed by the testable AttachmentPathResolver (containment-checked, 404 on miss). Shared PVC co-mounted read-only with fsGroup so Blazor can read agent-written files. - CLI: Plain + Spectre frontends print a placeholder line per attachment. Out of scope (noted in design doc): scheduled/subagent/A2A producing paths (buffer generalizes trivially), SVG->PNG rasterization, chat-platform proxies, attachments in history replay. Verified end-to-end on the live cluster: attach_image fires and stages; /attachments serves the exact bytes with correct content-type and 404s on traversal/missing; Blazor reads through the RO mount. 29 new unit tests; full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rockbot/templates/blazor/deployment.yaml | 17 ++ design/client-rendering-capabilities.md | 16 +- src/RockBot.Agent/AttachmentReplyTools.cs | 153 ++++++++++++++++++ src/RockBot.Agent/Program.cs | 6 + src/RockBot.Agent/UserMessageHandler.cs | 21 ++- src/RockBot.Host/ReplyAttachmentBuffer.cs | 56 +++++++ .../ServiceCollectionExtensions.cs | 1 + .../AgentAttachment.cs | 26 +++ .../AgentReply.cs | 9 ++ .../Components/AttachmentList.razor | 27 ++++ src/RockBot.UserProxy.Blazor/Pages/Chat.razor | 2 + src/RockBot.UserProxy.Blazor/Program.cs | 15 ++ .../Services/AttachmentPathResolver.cs | 98 +++++++++++ .../Services/ChatStateService.cs | 9 ++ .../wwwroot/css/app.css | 19 +++ .../AttachmentPlaceholder.cs | 18 +++ .../PlainConsoleFrontend.cs | 8 + .../SpectreConsoleFrontend.cs | 8 + .../AttachmentReplyToolsTests.cs | 147 +++++++++++++++++ .../AttachmentPlaceholderTests.cs | 32 ++++ .../ReplyAttachmentBufferTests.cs | 82 ++++++++++ .../AttachmentPathResolverTests.cs | 96 +++++++++++ .../AgentAttachmentTests.cs | 79 +++++++++ 23 files changed, 941 insertions(+), 4 deletions(-) create mode 100644 src/RockBot.Agent/AttachmentReplyTools.cs create mode 100644 src/RockBot.Host/ReplyAttachmentBuffer.cs create mode 100644 src/RockBot.UserProxy.Abstractions/AgentAttachment.cs create mode 100644 src/RockBot.UserProxy.Blazor/Components/AttachmentList.razor create mode 100644 src/RockBot.UserProxy.Blazor/Services/AttachmentPathResolver.cs create mode 100644 src/RockBot.UserProxy.Cli/AttachmentPlaceholder.cs create mode 100644 tests/RockBot.Agent.Tests/AttachmentReplyToolsTests.cs create mode 100644 tests/RockBot.Cli.Tests/AttachmentPlaceholderTests.cs create mode 100644 tests/RockBot.Host.Tests/ReplyAttachmentBufferTests.cs create mode 100644 tests/RockBot.UserProxy.Blazor.Tests/AttachmentPathResolverTests.cs create mode 100644 tests/RockBot.UserProxy.Tests/AgentAttachmentTests.cs diff --git a/deploy/helm/rockbot/templates/blazor/deployment.yaml b/deploy/helm/rockbot/templates/blazor/deployment.yaml index 4d623a56..0d2c7d00 100644 --- a/deploy/helm/rockbot/templates/blazor/deployment.yaml +++ b/deploy/helm/rockbot/templates/blazor/deployment.yaml @@ -20,6 +20,10 @@ spec: {{- include "rockbot.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: blazor spec: + # Match the shared-data fsGroup so files the agent wrote (group rw via its own fsGroup) + # are readable here. The PVC is co-mounted read-only solely to serve attachments. + securityContext: + fsGroup: {{ .Values.shared.fsGroup }} containers: - name: blazor image: {{ .Values.blazor.image.repository }}:{{ .Values.blazor.image.tag }} @@ -55,6 +59,10 @@ spec: secretKeyRef: name: {{ include "rockbot.secretName" . }} key: RabbitMq__Password + # Where the shared attachments directory is mounted — the /attachments endpoint + # resolves AgentReply.Attachments file references under ${ROCKBOT_SHARED_PATH}/attachments. + - name: ROCKBOT_SHARED_PATH + value: "/rockbot/shared" {{- if .Values.workiq.enabled }} # WorkIQ device-code flow needs the same Entra app registration # values the agent uses. Cache itself never lands on the Blazor pod. @@ -98,3 +106,12 @@ spec: periodSeconds: 5 resources: {{- toYaml .Values.blazor.resources | nindent 12 }} + volumeMounts: + # Read-only: Blazor only serves attachment bytes; the agent owns writes. + - name: shared-data + mountPath: /rockbot/shared + readOnly: true + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ include "rockbot.sharedPvcName" . }} diff --git a/design/client-rendering-capabilities.md b/design/client-rendering-capabilities.md index 7dc8af60..b795d5fb 100644 --- a/design/client-rendering-capabilities.md +++ b/design/client-rendering-capabilities.md @@ -351,9 +351,19 @@ would need re-negotiation when the surface changes. These are deliberately out of scope for v1: -- **`AgentReply.Attachments`** — needed for `ImageAttachment` capability to do - anything end-to-end. Real refactor (new field, proxy support for binary - payloads in each platform). Defer until a proxy that can use it ships. +- **`AgentReply.Attachments`** — ✅ **implemented** (issue #416, first slice). A new + `IReadOnlyList?` field on `AgentReply` carries out-of-band binaries as + shared-PVC **path references** (`{ mime, path, fileName? }`), reusing the MCP-attachment + scheme — bytes never ride the bus. Producing side: an `attach_image` LLM tool + (`AttachmentReplyTools`) validates a model-named file under the shared attachments dir and + stages it in a session-keyed `ReplyAttachmentBuffer`; `UserMessageHandler` drains the buffer + onto the final reply (the **user-message path** only in v1). Rendering side: Blazor + co-mounts the shared PVC read-only and serves bytes from a minimal `/attachments` endpoint, + rendering images as native `` **outside** the markdown sanitizer (which strips `` + and `data:` URLs); the CLI prints a placeholder line per attachment. + Still deferred: scheduled-task / subagent / A2A producing paths (the buffer generalizes + trivially), SVG→PNG rasterization, chat-platform proxies (none exist yet), and attachments in + conversation-history replay (replies are ephemeral; history stores text). - **Platform-native UI emission** — `DiscordEmbed` / `SlackBlockKit` / `TeamsAdaptiveCard` bits are reserved in the enum but no tooling produces them. Adding them is opportunistic upgrade work in each proxy (e.g., a diff --git a/src/RockBot.Agent/AttachmentReplyTools.cs b/src/RockBot.Agent/AttachmentReplyTools.cs new file mode 100644 index 00000000..1dc4fd0f --- /dev/null +++ b/src/RockBot.Agent/AttachmentReplyTools.cs @@ -0,0 +1,153 @@ +using System.ComponentModel; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using RockBot.Agent.McpBridge.Attachments; +using RockBot.Host; +using RockBot.UserProxy; + +namespace RockBot.Agent; + +/// +/// Per-session LLM tool that lets the agent attach an image file to its final reply. The file +/// must already exist under the shared attachments directory — produced by a script or MCP +/// tool — so the agent never handles bytes itself (the "Nothing trusts the LLM" rule). The +/// tool validates containment and existence, infers the MIME type from the extension when not +/// given, and stages an in the +/// keyed to this session. The reply-publishing path drains the buffer onto the final reply. +/// +public sealed class AttachmentReplyTools +{ + private readonly IAttachmentStorage _storage; + private readonly ReplyAttachmentBuffer _buffer; + private readonly string _sessionId; + private readonly ILogger _logger; + + public AttachmentReplyTools( + IAttachmentStorage storage, + ReplyAttachmentBuffer buffer, + string sessionId, + ILogger logger) + { + _storage = storage; + _buffer = buffer; + _sessionId = sessionId; + _logger = logger; + + Tools = [AIFunctionFactory.Create(AttachImage)]; + } + + public IList Tools { get; } + + [Description( + "Attach an image file to your reply so the user sees it rendered inline (e.g. a chart, " + + "diagram, or screenshot). The file must already exist in the shared attachments directory " + + "— first have a script or tool write it there, then call this with the filename. Do NOT " + + "embed images as markdown or data URLs; those are stripped. Only call this once the file " + + "exists. The image is shown to clients that can render it; others see a short placeholder line.")] + public string AttachImage( + [Description("Filename of the image under the shared attachments directory (e.g. 'chart.png'). " + + "Must be a file that already exists there.")] string path, + [Description("Optional MIME type (e.g. 'image/png'). Inferred from the file extension when omitted.")] string? mime = null, + [Description("Optional friendly display name shown to the user. Defaults to the filename.")] string? fileName = null) + { + _logger.LogInformation("Tool call: AttachImage(path={Path}, mime={Mime})", path, mime); + + if (string.IsNullOrWhiteSpace(path)) + return "Could not attach: no path provided. Pass the filename of an image already written to the shared attachments directory."; + + string resolved; + try + { + resolved = ResolveUnderBase(path); + } + catch (UnauthorizedAccessException) + { + return $"Could not attach '{path}': it is outside the shared attachments directory. " + + "Only files under the shared attachments directory can be attached."; + } + + if (!File.Exists(resolved)) + return $"Could not attach '{path}': no such file in the shared attachments directory. " + + "Write the image there first, then attach it."; + + var resolvedMime = string.IsNullOrWhiteSpace(mime) ? GuessMime(resolved) : mime.Trim(); + var leaf = Path.GetFileName(path); + var displayName = string.IsNullOrWhiteSpace(fileName) ? leaf : fileName.Trim(); + + _buffer.Add(_sessionId, new AgentAttachment + { + Mime = resolvedMime, + Path = leaf, + FileName = displayName + }); + + return $"Attached '{displayName}' ({resolvedMime}). It will be included with your reply."; + } + + /// + /// Resolves to an absolute path under , + /// throwing if it escapes the base. Mirrors + /// AttachmentStorage.ResolveReadPath so model-controlled input cannot reach arbitrary + /// filesystem locations. + /// + private string ResolveUnderBase(string path) + { + var fullBase = Path.GetFullPath(_storage.BasePath); + + string candidate; + if (Path.IsPathRooted(path)) + { + candidate = Path.GetFullPath(path); + } + else + { + // The shared-volume convention refers to files as `/`; the model may + // write `attachments/foo.png` even though BasePath already ends in `/attachments`. + // Strip the redundant leaf so it resolves to a single layer. + candidate = Path.GetFullPath(Path.Combine(fullBase, StripRedundantBaseLeaf(fullBase, path))); + } + + var baseWithSep = fullBase.EndsWith(Path.DirectorySeparatorChar) + ? fullBase + : fullBase + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(baseWithSep, StringComparison.OrdinalIgnoreCase) + && !candidate.Equals(fullBase, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException( + $"Attachment path '{path}' is outside the shared attachments directory '{_storage.BasePath}'."); + } + + return candidate; + } + + private static string StripRedundantBaseLeaf(string basePath, string relativePath) + { + var leaf = Path.GetFileName(basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (string.IsNullOrEmpty(leaf)) return relativePath; + + ReadOnlySpan span = relativePath; + if (span.StartsWith(leaf, StringComparison.OrdinalIgnoreCase) + && span.Length > leaf.Length + && (span[leaf.Length] == '/' || span[leaf.Length] == '\\')) + { + return relativePath[(leaf.Length + 1)..]; + } + return relativePath; + } + + private static string GuessMime(string fileName) + { + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + return ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + ".bmp" => "image/bmp", + ".pdf" => "application/pdf", + _ => "application/octet-stream" + }; + } +} diff --git a/src/RockBot.Agent/Program.cs b/src/RockBot.Agent/Program.cs index 69aa442b..0da09bc7 100644 --- a/src/RockBot.Agent/Program.cs +++ b/src/RockBot.Agent/Program.cs @@ -374,6 +374,12 @@ async Task BuildClientForTierAsync(LlmTierConfig config, string tie // without rebuilding the image. LlmPricing__ConfigPath in the ConfigMap overrides the default. builder.Services.Configure(builder.Configuration.GetSection("LlmPricing")); +// Shared attachments storage — the filesystem facade over ${ROCKBOT_SHARED_PATH}/attachments. +// Used by the attach_image reply tool to validate model-named files before referencing them. +// McpBridgeService keeps its own Lazy instance; this registration limits blast radius. +builder.Services.AddSingleton(); + // MCP bridge (replaces external RockBot.Tools.Mcp.Bridge process) builder.Services.Configure(builder.Configuration.GetSection("McpBridge")); builder.Services.AddSingleton(new McpArgGuardRegistration( diff --git a/src/RockBot.Agent/UserMessageHandler.cs b/src/RockBot.Agent/UserMessageHandler.cs index df3be991..e94bb03f 100644 --- a/src/RockBot.Agent/UserMessageHandler.cs +++ b/src/RockBot.Agent/UserMessageHandler.cs @@ -49,6 +49,8 @@ internal sealed class UserMessageHandler( ISessionTracker sessionTracker, SessionStartTracker sessionStartTracker, SessionClientCapabilityStore clientCapabilityStore, + ReplyAttachmentBuffer attachmentBuffer, + McpBridge.Attachments.IAttachmentStorage attachmentStorage, SessionOriginStore originStore, IOptions profileOptions, IWipTracker wipTracker, @@ -184,6 +186,10 @@ await conversationMemory.AddTurnAsync( var sessionNamespace = $"session/{message.SessionId}"; var sessionWorkingMemoryTools = new WorkingMemoryTools(workingMemory, sessionNamespace, logger); + // Per-session attachment tool — attach_image stages files for this session's final reply. + var attachmentReplyTools = new AttachmentReplyTools( + attachmentStorage, attachmentBuffer, message.SessionId, logger); + // Per-session skill tools with usage tracking var sessionSkillTools = new SkillTools(skillStore, llmClient, logger, message.SessionId, skillUsageStore); @@ -193,6 +199,7 @@ await conversationMemory.AddTurnAsync( var allTools = memoryTools.Tools .Concat(sessionWorkingMemoryTools.Tools) + .Concat(attachmentReplyTools.Tools) .Concat(sessionSkillTools.Tools) .Concat(rulesTools.Tools) .Concat(toolGuideTools.Tools) @@ -691,12 +698,24 @@ private async Task PublishReplyAsync( string content, string replyTo, string? correlationId, string sessionId, bool isFinal, CancellationToken ct) { + // Only final replies carry attachments — drain the per-session buffer so files staged + // by attach_image ride out with the answer and aren't replayed on a later turn. + // Progress/non-final replies never carry attachments. + IReadOnlyList? attachments = null; + if (isFinal) + { + var drained = attachmentBuffer.Drain(sessionId); + if (drained.Count > 0) + attachments = drained; + } + var reply = new AgentReply { Content = content, SessionId = sessionId, AgentName = agentNameHolder.DisplayName ?? agent.Name, - IsFinal = isFinal + IsFinal = isFinal, + Attachments = attachments }; var envelope = reply.ToEnvelope(source: agent.Name, correlationId: correlationId); await publisher.PublishAsync(replyTo, envelope, ct); diff --git a/src/RockBot.Host/ReplyAttachmentBuffer.cs b/src/RockBot.Host/ReplyAttachmentBuffer.cs new file mode 100644 index 00000000..f43f9287 --- /dev/null +++ b/src/RockBot.Host/ReplyAttachmentBuffer.cs @@ -0,0 +1,56 @@ +using RockBot.UserProxy; + +namespace RockBot.Host; + +/// +/// Session-keyed buffer of the agent has staged for the next +/// final reply. The attach_image LLM tool calls during a turn; the +/// reply-publishing path calls when it emits the final reply, moving the +/// staged attachments onto and clearing the buffer so +/// they aren't replayed on a later turn. +/// +/// Mirrors in shape and lifetime: a process-wide +/// singleton holding short-lived per-session metadata, guarded by a single lock. Registered +/// as a singleton in the agent host. +/// +/// +public sealed class ReplyAttachmentBuffer +{ + private readonly Dictionary> _bySession + = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _lock = new(); + + /// + /// Stage for 's next final reply. + /// + public void Add(string sessionId, AgentAttachment attachment) + { + lock (_lock) + { + if (!_bySession.TryGetValue(sessionId, out var list)) + _bySession[sessionId] = list = new List(); + list.Add(attachment); + } + } + + /// + /// Returns and clears the attachments staged for . Returns an + /// empty list when nothing is staged. Drains so a later reply in the same session starts clean. + /// + public IReadOnlyList Drain(string sessionId) + { + lock (_lock) + { + if (_bySession.Remove(sessionId, out var list)) + return list; + return []; + } + } + + /// Drop any staged attachments for without returning them. + public void Clear(string sessionId) + { + lock (_lock) + _bySession.Remove(sessionId); + } +} diff --git a/src/RockBot.Host/ServiceCollectionExtensions.cs b/src/RockBot.Host/ServiceCollectionExtensions.cs index 8f0f0967..558feceb 100644 --- a/src/RockBot.Host/ServiceCollectionExtensions.cs +++ b/src/RockBot.Host/ServiceCollectionExtensions.cs @@ -42,6 +42,7 @@ public static IServiceCollection AddRockBotHost( services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/RockBot.UserProxy.Abstractions/AgentAttachment.cs b/src/RockBot.UserProxy.Abstractions/AgentAttachment.cs new file mode 100644 index 00000000..e282b842 --- /dev/null +++ b/src/RockBot.UserProxy.Abstractions/AgentAttachment.cs @@ -0,0 +1,26 @@ +namespace RockBot.UserProxy; + +/// +/// A binary payload attached to an , carried as a path reference +/// rather than inline bytes. names a file the agent has produced (via a +/// script or MCP tool) under the shared attachments directory +/// (${ROCKBOT_SHARED_PATH}/attachments) — the same convention the MCP attachment +/// gateway speaks. Bytes never ride the bus: a capable frontend (e.g. Blazor) co-mounts the +/// shared volume and serves the file from its own endpoint; non-image frontends render a +/// placeholder. +/// +public sealed record AgentAttachment +{ + /// MIME type of the attachment (e.g. image/png). + public required string Mime { get; init; } + + /// + /// Bare filename or relative path under the shared attachments directory (e.g. + /// chart.png). Never an absolute filesystem path on the wire — the receiving + /// frontend resolves it under its own attachments base with a containment check. + /// + public required string Path { get; init; } + + /// Optional human-friendly display name; falls back to the leaf of . + public string? FileName { get; init; } +} diff --git a/src/RockBot.UserProxy.Abstractions/AgentReply.cs b/src/RockBot.UserProxy.Abstractions/AgentReply.cs index 349874cd..85c3ba85 100644 --- a/src/RockBot.UserProxy.Abstractions/AgentReply.cs +++ b/src/RockBot.UserProxy.Abstractions/AgentReply.cs @@ -28,4 +28,13 @@ public sealed record AgentReply /// or arrived in a different client. /// public ReplyOrigin? Origin { get; init; } + + /// + /// Out-of-band binary payloads (images, etc.) the agent produced for this reply, carried + /// as shared-volume path references rather than inline bytes. Only set on final replies — + /// progress/non-final replies never carry attachments. Null when the reply is text-only. + /// Capable frontends render these natively (Blazor serves them from an HTTP endpoint); + /// others print a placeholder line. Serializes for free via the camelCase STJ path. + /// + public IReadOnlyList? Attachments { get; init; } } diff --git a/src/RockBot.UserProxy.Blazor/Components/AttachmentList.razor b/src/RockBot.UserProxy.Blazor/Components/AttachmentList.razor new file mode 100644 index 00000000..024d86df --- /dev/null +++ b/src/RockBot.UserProxy.Blazor/Components/AttachmentList.razor @@ -0,0 +1,27 @@ +@* Renders agent reply attachments natively, OUTSIDE the markdown sanitizer (which strips + and data: URLs). Image MIME types render as served from the /attachments + endpoint; anything else renders as a download link. Bytes are served by the endpoint — + only a path reference reaches the browser here. *@ + +@if (Attachments is { Count: > 0 }) +{ +
+ @foreach (var att in Attachments) + { + var label = string.IsNullOrWhiteSpace(att.FileName) ? att.Path : att.FileName; + var url = $"/attachments?file={Uri.EscapeDataString(att.Path)}"; + @if (att.Mime.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + @label + } + else + { + 📎 @label + } + } +
+} + +@code { + [Parameter] public IReadOnlyList? Attachments { get; set; } +} diff --git a/src/RockBot.UserProxy.Blazor/Pages/Chat.razor b/src/RockBot.UserProxy.Blazor/Pages/Chat.razor index 94000299..546f95b6 100644 --- a/src/RockBot.UserProxy.Blazor/Pages/Chat.razor +++ b/src/RockBot.UserProxy.Blazor/Pages/Chat.razor @@ -134,6 +134,7 @@
@((MarkupString)SafeMarkdownRenderer.RenderSafe(message.Content))
+
@TimeZoneInfo.ConvertTimeFromUtc(message.Timestamp, _displayZone).ToString("HH:mm:ss")
@@ -173,6 +174,7 @@
@((MarkupString)SafeMarkdownRenderer.RenderSafe(message.Content))
+
@TimeZoneInfo.ConvertTimeFromUtc(message.Timestamp, _displayZone).ToString("HH:mm:ss")
diff --git a/src/RockBot.UserProxy.Blazor/Program.cs b/src/RockBot.UserProxy.Blazor/Program.cs index 78527e6e..d182c0b4 100644 --- a/src/RockBot.UserProxy.Blazor/Program.cs +++ b/src/RockBot.UserProxy.Blazor/Program.cs @@ -40,6 +40,21 @@ app.UseAntiforgery(); app.MapStaticAssets(); + +// Serves agent reply attachments from the shared PVC (co-mounted read-only). The agent +// references files by name on AgentReply.Attachments; bytes never ride the bus. The resolver +// enforces containment under the shared attachments directory so this endpoint can't be coaxed +// into serving arbitrary files via traversal or absolute paths. +var attachmentResolver = AttachmentPathResolver.FromEnvironment(); +app.MapGet("/attachments", (string? file) => +{ + var resolved = attachmentResolver.Resolve(file); + if (resolved is null) + return Results.NotFound(); + + return Results.File(resolved, AttachmentPathResolver.GuessMime(resolved)); +}); + app.MapRazorComponents() .AddInteractiveServerRenderMode(); diff --git a/src/RockBot.UserProxy.Blazor/Services/AttachmentPathResolver.cs b/src/RockBot.UserProxy.Blazor/Services/AttachmentPathResolver.cs new file mode 100644 index 00000000..60b52260 --- /dev/null +++ b/src/RockBot.UserProxy.Blazor/Services/AttachmentPathResolver.cs @@ -0,0 +1,98 @@ +namespace RockBot.UserProxy.Blazor.Services; + +/// +/// Resolves the file query parameter of the /attachments endpoint to an absolute +/// path under the shared attachments directory, rejecting any input that escapes the base +/// (parent-directory traversal, absolute paths pointing elsewhere). The Blazor pod co-mounts the +/// shared PVC read-only; this is the containment boundary that keeps the endpoint from serving +/// arbitrary files. Factored out of Program.cs so the boundary can be unit-tested. +/// +public sealed class AttachmentPathResolver +{ + /// Absolute base path of the attachments directory (e.g. /rockbot/shared/attachments). + public string BasePath { get; } + + public AttachmentPathResolver(string basePath) + { + BasePath = Path.GetFullPath(basePath ?? throw new ArgumentNullException(nameof(basePath))); + } + + /// + /// Resolves the base attachments directory from ROCKBOT_SHARED_PATH, falling back to + /// /rockbot/shared when the env var is unset — matching AttachmentStorage. + /// + public static AttachmentPathResolver FromEnvironment() + { + var sharedRoot = Environment.GetEnvironmentVariable("ROCKBOT_SHARED_PATH"); + if (string.IsNullOrWhiteSpace(sharedRoot)) + sharedRoot = "/rockbot/shared"; + return new AttachmentPathResolver(Path.Combine(sharedRoot, "attachments")); + } + + /// + /// Resolves (a bare filename or relative path under the base) to an + /// absolute path that exists inside . Returns null when the input + /// is empty, escapes the base, or the file does not exist. + /// + public string? Resolve(string? file) + { + if (string.IsNullOrWhiteSpace(file)) + return null; + + string candidate; + try + { + // Combine treats a rooted `file` as overriding the base; the containment check below + // catches that case and rejects it. Strip a redundant leading "attachments/" so the + // path convention used elsewhere resolves to a single layer. + var normalized = StripRedundantBaseLeaf(file); + candidate = Path.GetFullPath(Path.Combine(BasePath, normalized)); + } + catch (ArgumentException) + { + return null; // invalid path characters + } + + var baseWithSep = BasePath.EndsWith(Path.DirectorySeparatorChar) + ? BasePath + : BasePath + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(baseWithSep, StringComparison.OrdinalIgnoreCase)) + return null; + + return File.Exists(candidate) ? candidate : null; + } + + private string StripRedundantBaseLeaf(string relativePath) + { + var leaf = Path.GetFileName(BasePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (string.IsNullOrEmpty(leaf)) return relativePath; + + ReadOnlySpan span = relativePath; + if (span.StartsWith(leaf, StringComparison.OrdinalIgnoreCase) + && span.Length > leaf.Length + && (span[leaf.Length] == '/' || span[leaf.Length] == '\\')) + { + return relativePath[(leaf.Length + 1)..]; + } + return relativePath; + } + + /// + /// Best-effort MIME lookup from the file extension, for the Content-Type response header. + /// + public static string GuessMime(string fileName) + { + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + return ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + ".bmp" => "image/bmp", + ".pdf" => "application/pdf", + _ => "application/octet-stream" + }; + } +} diff --git a/src/RockBot.UserProxy.Blazor/Services/ChatStateService.cs b/src/RockBot.UserProxy.Blazor/Services/ChatStateService.cs index 503fbe5d..71f568a0 100644 --- a/src/RockBot.UserProxy.Blazor/Services/ChatStateService.cs +++ b/src/RockBot.UserProxy.Blazor/Services/ChatStateService.cs @@ -159,6 +159,7 @@ public void AddAgentReply(AgentReply reply, MessageCategory category = MessageCa AgentName = reply.AgentName, SessionId = reply.SessionId, ContentType = reply.ContentType, + Attachments = reply.Attachments, IsInterim = !reply.IsFinal, Category = category, IsExpanded = category is MessageCategory.PrimaryFinal @@ -429,6 +430,14 @@ public sealed class ChatMessage public string? UserId { get; init; } public string? SessionId { get; init; } public string? ContentType { get; init; } + + /// + /// Out-of-band attachments (images, etc.) carried on a final agent reply. Rendered natively + /// (outside the markdown sanitizer) as <img> for image MIME types or a download link + /// otherwise. Null for text-only replies. + /// + public IReadOnlyList? Attachments { get; init; } + public bool IsError { get; init; } /// diff --git a/src/RockBot.UserProxy.Blazor/wwwroot/css/app.css b/src/RockBot.UserProxy.Blazor/wwwroot/css/app.css index 49593d00..388ab4e4 100644 --- a/src/RockBot.UserProxy.Blazor/wwwroot/css/app.css +++ b/src/RockBot.UserProxy.Blazor/wwwroot/css/app.css @@ -84,6 +84,25 @@ body { margin-bottom: 0; } +/* Native reply attachments — rendered outside the markdown sanitizer. */ +.message-attachments { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.message-attachments .attachment-image { + max-width: 100%; + height: auto; + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.message-attachments .attachment-link { + display: inline-block; + width: fit-content; +} + /* Links inside user bubbles (blue bg) must be white to stay visible */ .user-message .message-bubble .message-content a { color: rgba(255, 255, 255, 0.9); diff --git a/src/RockBot.UserProxy.Cli/AttachmentPlaceholder.cs b/src/RockBot.UserProxy.Cli/AttachmentPlaceholder.cs new file mode 100644 index 00000000..fffadad6 --- /dev/null +++ b/src/RockBot.UserProxy.Cli/AttachmentPlaceholder.cs @@ -0,0 +1,18 @@ +namespace RockBot.UserProxy.Cli; + +/// +/// Formats a one-line placeholder for an the CLI can't render +/// inline. Shared by the plain and Spectre frontends so the wording stays consistent, e.g. +/// [image: chart.png (image/png)] or [attachment: report.pdf (application/pdf)]. +/// +internal static class AttachmentPlaceholder +{ + public static string Render(AgentAttachment attachment) + { + var label = string.IsNullOrWhiteSpace(attachment.FileName) ? attachment.Path : attachment.FileName; + var kind = attachment.Mime.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + ? "image" + : "attachment"; + return $"[{kind}: {label} ({attachment.Mime})]"; + } +} diff --git a/src/RockBot.UserProxy.Cli/PlainConsoleFrontend.cs b/src/RockBot.UserProxy.Cli/PlainConsoleFrontend.cs index 63b4c9b8..5f8059b9 100644 --- a/src/RockBot.UserProxy.Cli/PlainConsoleFrontend.cs +++ b/src/RockBot.UserProxy.Cli/PlainConsoleFrontend.cs @@ -17,6 +17,14 @@ public Task DisplayReplyAsync(AgentReply reply, CancellationToken cancellationTo if (anchor is not null) Console.Out.WriteLine(anchor); Console.Out.WriteLine(HtmlPlainTextRenderer.StripHtml(reply.Content)); + + // The plain console can't render binary attachments — print a placeholder line per + // attachment so the user knows one was produced (and where) without dumping bytes. + if (reply.Attachments is { Count: > 0 }) + { + foreach (var att in reply.Attachments) + Console.Out.WriteLine(AttachmentPlaceholder.Render(att)); + } return Task.CompletedTask; } diff --git a/src/RockBot.UserProxy.Cli/SpectreConsoleFrontend.cs b/src/RockBot.UserProxy.Cli/SpectreConsoleFrontend.cs index 7a1b2b6f..2628fe71 100644 --- a/src/RockBot.UserProxy.Cli/SpectreConsoleFrontend.cs +++ b/src/RockBot.UserProxy.Cli/SpectreConsoleFrontend.cs @@ -23,6 +23,14 @@ public Task DisplayReplyAsync(AgentReply reply, CancellationToken cancellationTo }; AnsiConsole.Write(panel); + + // The terminal can't render binary attachments — show a dim placeholder line per + // attachment so the user knows one was produced (and its filename / type). + if (reply.Attachments is { Count: > 0 }) + { + foreach (var att in reply.Attachments) + AnsiConsole.MarkupLine($"[dim]{Markup.Escape(AttachmentPlaceholder.Render(att))}[/]"); + } return Task.CompletedTask; } diff --git a/tests/RockBot.Agent.Tests/AttachmentReplyToolsTests.cs b/tests/RockBot.Agent.Tests/AttachmentReplyToolsTests.cs new file mode 100644 index 00000000..196592ba --- /dev/null +++ b/tests/RockBot.Agent.Tests/AttachmentReplyToolsTests.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Logging.Abstractions; +using RockBot.Agent.McpBridge.Attachments; +using RockBot.Host; + +namespace RockBot.Agent.Tests; + +[TestClass] +public sealed class AttachmentReplyToolsTests +{ + private string _baseDir = null!; + private AttachmentStorage _storage = null!; + private ReplyAttachmentBuffer _buffer = null!; + private AttachmentReplyTools _tools = null!; + + [TestInitialize] + public void Init() + { + _baseDir = Path.Combine(Path.GetTempPath(), "rb-attach-tests", Guid.NewGuid().ToString("N")); + _storage = new AttachmentStorage(_baseDir); // creates the directory + _buffer = new ReplyAttachmentBuffer(); + _tools = new AttachmentReplyTools(_storage, _buffer, "session-1", NullLogger.Instance); + } + + [TestCleanup] + public void Cleanup() + { + try { Directory.Delete(_baseDir, recursive: true); } catch { /* best effort */ } + } + + private void WriteFile(string name, byte[]? bytes = null) + => File.WriteAllBytes(Path.Combine(_baseDir, name), bytes ?? [1, 2, 3]); + + [TestMethod] + public void AttachImage_RecordsIntoBuffer_ForExistingFile() + { + WriteFile("chart.png"); + + var result = _tools.AttachImage("chart.png"); + + StringAssert.Contains(result, "Attached"); + var drained = _buffer.Drain("session-1"); + Assert.AreEqual(1, drained.Count); + Assert.AreEqual("chart.png", drained[0].Path); + Assert.AreEqual("image/png", drained[0].Mime); + Assert.AreEqual("chart.png", drained[0].FileName); + } + + [TestMethod] + public void AttachImage_InfersMime_FromExtension() + { + WriteFile("photo.jpeg"); + + _tools.AttachImage("photo.jpeg"); + + Assert.AreEqual("image/jpeg", _buffer.Drain("session-1")[0].Mime); + } + + [TestMethod] + public void AttachImage_UsesExplicitMime_WhenProvided() + { + WriteFile("diagram.bin"); + + _tools.AttachImage("diagram.bin", mime: "image/svg+xml"); + + Assert.AreEqual("image/svg+xml", _buffer.Drain("session-1")[0].Mime); + } + + [TestMethod] + public void AttachImage_UsesFriendlyName_WhenProvided() + { + WriteFile("chart.png"); + + _tools.AttachImage("chart.png", fileName: "Quarterly results"); + + Assert.AreEqual("Quarterly results", _buffer.Drain("session-1")[0].FileName); + } + + [TestMethod] + public void AttachImage_Rejects_NonExistentFile() + { + var result = _tools.AttachImage("missing.png"); + + StringAssert.Contains(result, "no such file"); + Assert.AreEqual(0, _buffer.Drain("session-1").Count); + } + + [TestMethod] + public void AttachImage_Rejects_TraversalOutsideBase() + { + var result = _tools.AttachImage("../escape.png"); + + StringAssert.Contains(result, "outside"); + Assert.AreEqual(0, _buffer.Drain("session-1").Count); + } + + [TestMethod] + public void AttachImage_Rejects_AbsolutePathOutsideBase() + { + var outside = Path.Combine(Path.GetTempPath(), "rb-attach-outside.png"); + File.WriteAllBytes(outside, [1, 2, 3]); + try + { + var result = _tools.AttachImage(outside); + + StringAssert.Contains(result, "outside"); + Assert.AreEqual(0, _buffer.Drain("session-1").Count); + } + finally + { + try { File.Delete(outside); } catch { /* best effort */ } + } + } + + [TestMethod] + public void AttachImage_Rejects_EmptyPath() + { + var result = _tools.AttachImage(""); + + StringAssert.Contains(result, "no path"); + Assert.AreEqual(0, _buffer.Drain("session-1").Count); + } + + [TestMethod] + public void AttachImage_StripsRedundantAttachmentsLeaf() + { + // When BasePath ends in /attachments, the model may still say "attachments/chart.png". + // The leaf-strip must resolve that to /chart.png, not /attachments/chart.png. + var dir = Path.Combine(Path.GetTempPath(), "rb-attach-leaf", Guid.NewGuid().ToString("N"), "attachments"); + var storage = new AttachmentStorage(dir); + var buffer = new ReplyAttachmentBuffer(); + var tools = new AttachmentReplyTools(storage, buffer, "s", NullLogger.Instance); + File.WriteAllBytes(Path.Combine(dir, "chart.png"), [1, 2, 3]); + try + { + var result = tools.AttachImage("attachments/chart.png"); + + StringAssert.Contains(result, "Attached"); + var drained = buffer.Drain("s"); + Assert.AreEqual(1, drained.Count); + Assert.AreEqual("chart.png", drained[0].Path); + } + finally + { + try { Directory.Delete(Path.GetDirectoryName(dir)!, recursive: true); } catch { /* best effort */ } + } + } +} diff --git a/tests/RockBot.Cli.Tests/AttachmentPlaceholderTests.cs b/tests/RockBot.Cli.Tests/AttachmentPlaceholderTests.cs new file mode 100644 index 00000000..442e284f --- /dev/null +++ b/tests/RockBot.Cli.Tests/AttachmentPlaceholderTests.cs @@ -0,0 +1,32 @@ +using RockBot.UserProxy; +using RockBot.UserProxy.Cli; + +namespace RockBot.Cli.Tests; + +[TestClass] +public sealed class AttachmentPlaceholderTests +{ + [TestMethod] + public void Render_Image_UsesImageLabelAndFriendlyName() + { + var att = new AgentAttachment { Mime = "image/png", Path = "chart.png", FileName = "Q3 chart.png" }; + + Assert.AreEqual("[image: Q3 chart.png (image/png)]", AttachmentPlaceholder.Render(att)); + } + + [TestMethod] + public void Render_Image_FallsBackToPath_WhenNoFileName() + { + var att = new AgentAttachment { Mime = "image/jpeg", Path = "photo.jpg" }; + + Assert.AreEqual("[image: photo.jpg (image/jpeg)]", AttachmentPlaceholder.Render(att)); + } + + [TestMethod] + public void Render_NonImage_UsesAttachmentLabel() + { + var att = new AgentAttachment { Mime = "application/pdf", Path = "report.pdf" }; + + Assert.AreEqual("[attachment: report.pdf (application/pdf)]", AttachmentPlaceholder.Render(att)); + } +} diff --git a/tests/RockBot.Host.Tests/ReplyAttachmentBufferTests.cs b/tests/RockBot.Host.Tests/ReplyAttachmentBufferTests.cs new file mode 100644 index 00000000..5d4d4f6e --- /dev/null +++ b/tests/RockBot.Host.Tests/ReplyAttachmentBufferTests.cs @@ -0,0 +1,82 @@ +using RockBot.UserProxy; + +namespace RockBot.Host.Tests; + +[TestClass] +public sealed class ReplyAttachmentBufferTests +{ + private static AgentAttachment Att(string path, string mime = "image/png") + => new() { Mime = mime, Path = path }; + + [TestMethod] + public void Drain_ReturnsEmpty_WhenNothingStaged() + { + var buffer = new ReplyAttachmentBuffer(); + + var drained = buffer.Drain("unseen"); + + Assert.AreEqual(0, drained.Count); + } + + [TestMethod] + public void Add_Then_Drain_ReturnsStagedAttachments_InOrder() + { + var buffer = new ReplyAttachmentBuffer(); + buffer.Add("s1", Att("a.png")); + buffer.Add("s1", Att("b.png")); + + var drained = buffer.Drain("s1"); + + Assert.AreEqual(2, drained.Count); + Assert.AreEqual("a.png", drained[0].Path); + Assert.AreEqual("b.png", drained[1].Path); + } + + [TestMethod] + public void Drain_Clears_SoSecondDrainIsEmpty() + { + var buffer = new ReplyAttachmentBuffer(); + buffer.Add("s1", Att("a.png")); + + var first = buffer.Drain("s1"); + var second = buffer.Drain("s1"); + + Assert.AreEqual(1, first.Count); + Assert.AreEqual(0, second.Count); + } + + [TestMethod] + public void Sessions_AreIsolated() + { + var buffer = new ReplyAttachmentBuffer(); + buffer.Add("a", Att("a.png")); + buffer.Add("b", Att("b.png")); + + var drainedA = buffer.Drain("a"); + + Assert.AreEqual(1, drainedA.Count); + Assert.AreEqual("a.png", drainedA[0].Path); + // Draining a did not touch b. + Assert.AreEqual(1, buffer.Drain("b").Count); + } + + [TestMethod] + public void SessionIds_AreCaseInsensitive() + { + var buffer = new ReplyAttachmentBuffer(); + buffer.Add("Session-A", Att("a.png")); + + Assert.AreEqual(1, buffer.Drain("session-a").Count); + } + + [TestMethod] + public void Clear_DropsStagedAttachments() + { + var buffer = new ReplyAttachmentBuffer(); + buffer.Add("s1", Att("a.png")); + + buffer.Clear("s1"); + + Assert.AreEqual(0, buffer.Drain("s1").Count); + } +} diff --git a/tests/RockBot.UserProxy.Blazor.Tests/AttachmentPathResolverTests.cs b/tests/RockBot.UserProxy.Blazor.Tests/AttachmentPathResolverTests.cs new file mode 100644 index 00000000..1cdacdf3 --- /dev/null +++ b/tests/RockBot.UserProxy.Blazor.Tests/AttachmentPathResolverTests.cs @@ -0,0 +1,96 @@ +using RockBot.UserProxy.Blazor.Services; + +namespace RockBot.UserProxy.Blazor.Tests; + +[TestClass] +public sealed class AttachmentPathResolverTests +{ + private string _baseDir = null!; + private AttachmentPathResolver _resolver = null!; + + [TestInitialize] + public void Init() + { + _baseDir = Path.Combine(Path.GetTempPath(), "rb-resolver-tests", Guid.NewGuid().ToString("N"), "attachments"); + Directory.CreateDirectory(_baseDir); + _resolver = new AttachmentPathResolver(_baseDir); + } + + [TestCleanup] + public void Cleanup() + { + try { Directory.Delete(Path.GetDirectoryName(_baseDir)!, recursive: true); } catch { /* best effort */ } + } + + private string WriteFile(string name) + { + var full = Path.Combine(_baseDir, name); + File.WriteAllBytes(full, [1, 2, 3]); + return full; + } + + [TestMethod] + public void Resolve_ValidFile_ReturnsAbsolutePath() + { + var full = WriteFile("chart.png"); + + var resolved = _resolver.Resolve("chart.png"); + + Assert.AreEqual(Path.GetFullPath(full), resolved); + } + + [TestMethod] + public void Resolve_Null_ReturnsNull() + => Assert.IsNull(_resolver.Resolve(null)); + + [TestMethod] + public void Resolve_Empty_ReturnsNull() + => Assert.IsNull(_resolver.Resolve(" ")); + + [TestMethod] + public void Resolve_MissingFile_ReturnsNull() + => Assert.IsNull(_resolver.Resolve("nope.png")); + + [TestMethod] + public void Resolve_ParentTraversal_ReturnsNull() + { + // Even if a file exists one level up, traversal must be rejected. + File.WriteAllBytes(Path.Combine(Path.GetDirectoryName(_baseDir)!, "secret.png"), [9]); + + Assert.IsNull(_resolver.Resolve("../secret.png")); + } + + [TestMethod] + public void Resolve_AbsolutePathOutsideBase_ReturnsNull() + { + var outside = Path.Combine(Path.GetTempPath(), $"rb-outside-{Guid.NewGuid():N}.png"); + File.WriteAllBytes(outside, [1]); + try + { + Assert.IsNull(_resolver.Resolve(outside)); + } + finally + { + try { File.Delete(outside); } catch { /* best effort */ } + } + } + + [TestMethod] + public void Resolve_RedundantAttachmentsLeaf_ResolvesToSingleLayer() + { + var full = WriteFile("chart.png"); + + var resolved = _resolver.Resolve("attachments/chart.png"); + + Assert.AreEqual(Path.GetFullPath(full), resolved); + } + + [TestMethod] + public void GuessMime_KnownExtensions() + { + Assert.AreEqual("image/png", AttachmentPathResolver.GuessMime("a.png")); + Assert.AreEqual("image/jpeg", AttachmentPathResolver.GuessMime("a.JPG")); + Assert.AreEqual("application/pdf", AttachmentPathResolver.GuessMime("a.pdf")); + Assert.AreEqual("application/octet-stream", AttachmentPathResolver.GuessMime("a.unknown")); + } +} diff --git a/tests/RockBot.UserProxy.Tests/AgentAttachmentTests.cs b/tests/RockBot.UserProxy.Tests/AgentAttachmentTests.cs new file mode 100644 index 00000000..d6ec207b --- /dev/null +++ b/tests/RockBot.UserProxy.Tests/AgentAttachmentTests.cs @@ -0,0 +1,79 @@ +using System.Text; +using System.Text.Json; +using RockBot.Messaging; + +namespace RockBot.UserProxy.Tests; + +[TestClass] +public sealed class AgentAttachmentTests +{ + [TestMethod] + public void AgentReply_WithAttachments_RoundTrips_ThroughEnvelope() + { + var original = new AgentReply + { + Content = "Here's your chart", + SessionId = "session-1", + AgentName = "agent-alpha", + IsFinal = true, + Attachments = + [ + new AgentAttachment { Mime = "image/png", Path = "chart.png", FileName = "Q3 chart.png" }, + new AgentAttachment { Mime = "application/pdf", Path = "report.pdf" } + ] + }; + + var envelope = original.ToEnvelope(source: "agent-alpha"); + var deserialized = envelope.GetPayload(); + + Assert.IsNotNull(deserialized); + Assert.IsNotNull(deserialized.Attachments); + Assert.AreEqual(2, deserialized.Attachments.Count); + Assert.AreEqual("image/png", deserialized.Attachments[0].Mime); + Assert.AreEqual("chart.png", deserialized.Attachments[0].Path); + Assert.AreEqual("Q3 chart.png", deserialized.Attachments[0].FileName); + Assert.AreEqual("application/pdf", deserialized.Attachments[1].Mime); + Assert.AreEqual("report.pdf", deserialized.Attachments[1].Path); + Assert.IsNull(deserialized.Attachments[1].FileName); + } + + [TestMethod] + public void AgentReply_WithoutAttachments_RoundTrips_AsNull() + { + var original = new AgentReply + { + Content = "Just text", + SessionId = "s", + AgentName = "a" + }; + + var envelope = original.ToEnvelope(source: "a"); + var deserialized = envelope.GetPayload(); + + Assert.IsNotNull(deserialized); + Assert.IsNull(deserialized.Attachments); + } + + [TestMethod] + public void AgentAttachment_Serializes_WithCamelCaseKeys() + { + var reply = new AgentReply + { + Content = "x", + SessionId = "s", + AgentName = "a", + Attachments = [new AgentAttachment { Mime = "image/png", Path = "chart.png", FileName = "c.png" }] + }; + + var envelope = reply.ToEnvelope(source: "a"); + var json = Encoding.UTF8.GetString(envelope.Body.Span); + + using var doc = JsonDocument.Parse(json); + var attachments = doc.RootElement.GetProperty("attachments"); + Assert.AreEqual(1, attachments.GetArrayLength()); + var first = attachments[0]; + Assert.AreEqual("image/png", first.GetProperty("mime").GetString()); + Assert.AreEqual("chart.png", first.GetProperty("path").GetString()); + Assert.AreEqual("c.png", first.GetProperty("fileName").GetString()); + } +}