feat(harness): add deliver_artifact tool for sandboxed agents - #2667
feat(harness): add deliver_artifact tool for sandboxed agents#2667chcodex wants to merge 7 commits into
Conversation
…rget SPI Sandboxed agents can hand produced artifacts to a configured destination outside the workspace via the generic deliver_artifact tool. The tool downloads file bytes from the filesystem and delegates transport to a business-agnostic ArtifactDeliveryTarget. The sandbox workspace prompt tells the model to use it when configured. The tool is exposed only to the main agent, honors disableFilesystemTools, and rejects unsafe target file names.
0ee406d to
6cf458a
Compare
WorkspaceManager(workspace, filesystem) opens the SQLite-backed WorkspaceIndex (.index/workspace.db), whose JDBC driver keeps a file handle open. The test never closed the manager, so JUnit @tempdir cleanup failed on Windows with DirectoryNotEmptyException, breaking the build (windows-latest) CI job. Wrap the manager in try-with-resources.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR introduces a generic, business-agnostic artifact delivery mechanism for sandboxed agents. It adds an ArtifactDeliveryTarget SPI, a deliver_artifact tool registered only on the main agent, and corresponding sandbox prompt updates. The design is clean: the framework stays transport-agnostic (WebDAV, object store, etc. are implemented by the application), the tool validates fileName against path traversal, and the builder correctly suppresses the tool when disableFilesystemTools is set. Test coverage is solid with 13+ unit tests covering happy paths, input validation, error branches, and subagent isolation. The PR resolves #2663 appropriately.
| boolean effectiveForce = Boolean.TRUE.equals(force); | ||
|
|
||
| List<FileDownloadResponse> responses = | ||
| filesystem.downloadFiles(runtimeContext, List.of(normalized)); |
There was a problem hiding this comment.
[major] No file size guard. downloadFiles() loads the entire file into a byte[] which is then wrapped in ArtifactDeliveryRequest and forwarded to the target. The tool description explicitly mentions "archives" as a use case. A sandboxed agent could produce a very large file and trigger OOM or excessive GC pressure. Consider adding a configurable maxArtifactSizeBytes on the builder and returning an error before the download when the filesystem can report size, or after download if response.content().length exceeds the limit.
| * constructed subagents, which return plain text results for the main agent to deliver. | ||
| * | ||
| * <p>Note: the tool reads files from the agent filesystem, so it is also suppressed when | ||
| * {@link #disableFilesystemTools()} is used — combine the two only when you intend the |
There was a problem hiding this comment.
[nitpick] Self-contradictory Javadoc. The note reads: "it is also suppressed when disableFilesystemTools() is used — combine the two only when you intend the delivery tool to remain available." The second clause implies the tool will remain available when combining both, but the code actually suppresses it. Suggested rewrite to clarify the suppression semantics.
There was a problem hiding this comment.
✅ Verified as addressed in 8b3bd29: Javadoc rewritten to clearly state Combining both leaves the tool unregistered, resolving the self-contradiction.
| && !name.equals(".."); | ||
| } | ||
|
|
||
| private static String basename(String path) { |
There was a problem hiding this comment.
[nitpick] isPlainFileName does not reject NUL bytes. While LLMs rarely produce \0, some target implementations (C-based file APIs, certain object stores) truncate file names at NUL. Adding && name.indexOf('\0') < 0 would close this edge case cheaply.
There was a problem hiding this comment.
✅ Verified as addressed in 8b3bd29: isPlainFileName now includes name.indexOf('\0') < 0 check, exactly as suggested.
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR introduces a generic, business-agnostic artifact delivery mechanism for sandboxed agents. It adds an ArtifactDeliveryTarget SPI, a deliver_artifact tool registered only on the main agent, and corresponding sandbox prompt updates. The design is clean: the framework stays transport-agnostic (WebDAV, object store, etc. are implemented by the application), the tool validates fileName against path traversal, and the builder correctly suppresses the tool when disableFilesystemTools is set. Test coverage is solid with 13+ unit tests covering happy paths, input validation, error branches, and subagent isolation. The PR resolves #2663 appropriately.
| boolean effectiveForce = Boolean.TRUE.equals(force); | ||
|
|
||
| List<FileDownloadResponse> responses = | ||
| filesystem.downloadFiles(runtimeContext, List.of(normalized)); |
There was a problem hiding this comment.
[major] No file size guard. downloadFiles() loads the entire file into a byte[] which is then wrapped in ArtifactDeliveryRequest and forwarded to the target. The tool description explicitly mentions "archives" as a use case. A sandboxed agent could produce a very large file and trigger OOM or excessive GC pressure. Consider adding a configurable maxArtifactSizeBytes on the builder and returning an error before the download when the filesystem can report size, or after download if response.content().length exceeds the limit.
There was a problem hiding this comment.
Thanks for the review. This concern is acknowledged, but it is not introduced by this PR: AbstractFilesystem.downloadFiles(...) returning List<FileDownloadResponse> with a byte[] content is the framework's existing contract (also used by WorkspaceSkillRepository), and deliver_artifact simply reuses that API without adding new in-memory amplification. A real fix needs a streaming download API at the filesystem layer plus streaming support in sandbox backends — a framework-level design change beyond the scope of this PR. We will track it as a follow-up (streaming artifact delivery); this PR intentionally leaves it unchanged.
| * constructed subagents, which return plain text results for the main agent to deliver. | ||
| * | ||
| * <p>Note: the tool reads files from the agent filesystem, so it is also suppressed when | ||
| * {@link #disableFilesystemTools()} is used — combine the two only when you intend the |
There was a problem hiding this comment.
[nitpick] Self-contradictory Javadoc. The note reads: "it is also suppressed when disableFilesystemTools() is used — combine the two only when you intend the delivery tool to remain available." The second clause implies the tool will remain available when combining both, but the code actually suppresses it. Suggested rewrite to clarify the suppression semantics.
There was a problem hiding this comment.
✅ Verified as addressed in 8b3bd29: Javadoc rewritten to clearly state Combining both leaves the tool unregistered, resolving the self-contradiction.
| && !name.equals(".."); | ||
| } | ||
|
|
||
| private static String basename(String path) { |
There was a problem hiding this comment.
[nitpick] isPlainFileName does not reject NUL bytes. While LLMs rarely produce \0, some target implementations (C-based file APIs, certain object stores) truncate file names at NUL. Adding && name.indexOf('\0') < 0 would close this edge case cheaply.
There was a problem hiding this comment.
✅ Verified as addressed in 8b3bd29: isPlainFileName now includes name.indexOf('\0') < 0 check, exactly as suggested.
- Clarify artifactDeliveryTarget javadoc: combined with disableFilesystemTools the tool stays unregistered (was self-contradictory) - Reject NUL bytes in the target file name in addition to path separators - Cover the NUL rejection with a unit test
@jujn Actual test results are OK
|

Fixes #2663
Problem
In sandbox mode,
WorkspaceContextMiddlewarerenders a## Workspaceparagraph telling the model to "use the upload/download tools to move files across the boundary". The harness exposes no suchAgentTool—FilesystemToolonly registersread_file/write_file/edit_file/grep_files/glob_files/list_files, andSandboxFileTransferis an internal mechanism invisible to the LLM. The instruction is a dead reference: an agent that produces an artifact inside a sandbox has no supported way to hand it out.Change
Introduces a generic, business-agnostic artifact delivery path for sandboxed agents:
ArtifactDeliveryTargetSPI — application implementsdeliver(RuntimeContext, ArtifactDeliveryRequest) -> ArtifactDeliveryResultand owns destination logic (WebDAV, object store, project artifact store, …).deliver_artifacttool — registered viaHarnessAgent.Builder.artifactDeliveryTarget(...). It downloads the file bytes from the agent filesystem and delegates transport to the target.deliver_artifact; otherwise it states plainly that there is no mechanism for moving files across the boundary. The old dead reference to "upload/download tools" is gone.disableFilesystemTools()is set, keeping file-access isolation intact.fileNamemust be a plain file name — path separators,.and..are rejected, and target names are derived from a cleaned basename.ArtifactDeliveryRequest.fileName()is documented as already validated, so targets can resolve it directly.Tests
ArtifactDeliveryToolTest— download/forward, defaults, normalization, failures, conflict advice, and new fileName validation cases.WorkspaceContextMiddlewareSandboxPromptTest— both sandbox prompt branches.HarnessAgentTest— registration,disableFilesystemToolsinteraction, main-agent-only exposure to subagents, and prompt integration.