Repository navigation
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
oss-maintainer
left a comment
There was a problem hiding this comment.
✅ Approved — Major improvement over the inline Python approach.
The old python3 -c approach was fragile (JSON escaping gaps, \n literal issues, hard Python dependency). The new download → FilesystemUtils.performStringReplacement() → upload path:
- Eliminates ~90 lines of string building and parsing
- Reuses the same replacement logic as
LocalFilesystemandRemoteFilesystem - Removes the
python3runtime dependency - The
ReplacementResultrecord is a nice improvement over theObject[]return type
Good test coverage with both unit tests (spy-based) and integration tests (real download/upload).
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
Refactoring of the filesystem edit() pipeline: replaced untyped Object[] return with type-safe ReplacementResult record, and rewrote BaseSandboxFilesystem.edit() from remote Python-script to download-replace-upload pattern. Note: title says A2A server but diff contains only filesystem refactoring.
(inline comments could not be attached — line numbers fell outside PR hunks. See archived report.)
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
Refactoring of the filesystem edit() pipeline: replaced untyped Object[] return with type-safe ReplacementResult record, and rewrote BaseSandboxFilesystem.edit() from remote Python-script to download-replace-upload pattern. Note: title says A2A server but diff contains only filesystem refactoring.
(inline comments could not be attached — line numbers fell outside PR hunks. See archived report.)
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
Refactoring of the filesystem edit() pipeline: replaced untyped Object[] return with type-safe ReplacementResult record, and rewrote BaseSandboxFilesystem.edit() from remote Python-script to download-replace-upload pattern. Note: title says A2A server but diff contains only filesystem refactoring.
(inline comments could not be attached — line numbers fell outside PR hunks. See archived report.)
|
This PR currently conflicts with git fetch upstream
git checkout fix/sandbox-edit-python-to-java
git rebase upstream/main
# resolve conflicts, then:
git push --force-with-leaseThis is a one-time reminder. Once the conflicts are resolved, comment Automated notification by github-manager-bot |
7258661 to
325fdca
Compare
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review at 325fdca0. The branch was force-pushed onto current main, so the previous APPROVED (recorded at d13ac57d) no longer applies to any commit that exists on this PR — this is effectively a full review of a rebase plus a design change: the approved plan was download → FilesystemUtils → re-upload, and the head now runs a static Python script inside the sandbox with the Java transfer path demoted to a fallback.
The core design is right, and better than what was approved in July:
- User content never enters the command string.
old/newcross the boundary as files andsys.argvcarries paths only, which structurally removes the escaping/python3 -cinjection surface the old implementation relied on.shellQuoteis applied to every path argument, and theedit_native_multilineParams_notInCommandStringtest asserts the leakage property directly rather than just the outcome. parseErrorTokenreads theerrorfield exactly instead of substring-matching the payload, andedit_native_writeFailedDetailWithErrorLikePath_notMisclassifiedproves the case that motivated it (a path containingstring_not_found).mapNativeResultcovers every script error token plus the unknown-error default.- Exit-code passthrough (
__ec=$?; ...; exit $__ec) is what makes the 127 detection trustworthy, andisPythonMissingadditionally handles backends that report only on stderr with exit 0 — both are tested. ReplacementResultreplaces theObject[]return with a typed record acrossLocalFilesystem/RemoteFilesystem/sandbox, andnewString == nullis now a documented deletion rather than an"null"-literal or NPE.- 269 new test lines, including six real
LocalShellSandboxFilesystemend-to-end cases (special characters,replaceAll, not-found, multiple occurrences). CI green (6/6),license/clasigned, and the merge conflict flagged on 2026-09-17 is resolved cleanly (MERGEABLE/CLEAN).
Findings (4, one warning):
- [Warning]
tmp_out = real + ".agentscope-edit-tmp-" + str(os.getpid())is not unique per edit when a sandbox backend reuses a long-lived shell process, so two concurrent edits of the same target can share the temp file and one silently loses its update while still reportingcountsuccess — the param dir right below already uses a UUID for exactly this reason (BaseSandboxFilesystem.java:287). - [Info] The transfer fallback decodes with
new String(bytes, UTF_8), which replaces malformed bytes instead of failing like the Python path does, so on apython3-less sandbox a legacy-encoding text file gets edited successfully and silently re-encoded (BaseSandboxFilesystem.java:535). - [Info] The non-writable-directory fallback reopens the target with
"wb", so a mid-write I/O error truncates the file instead of leaving it unchanged (BaseSandboxFilesystem.java:307). - [Info]
os.path.realpathis the right call for workspace symlinks and mount locality; a test asserting the symlink is still a symlink after an edit would lock that behavior in (BaseSandboxFilesystem.java:285).
Commenting rather than re-approving because the head changed design after the previous approval; the items above are small and the temp-file uniqueness one is worth fixing before merge.
Automated review by github-manager-bot
| # itself would break workspace symlinks) and stays on target's mount. | ||
| real = os.path.realpath(target) | ||
| st = os.stat(real) | ||
| tmp_out = real + ".agentscope-edit-tmp-" + str(os.getpid()) |
There was a problem hiding this comment.
[Warning] PID is not a unique temp-file suffix inside a sandbox.
tmp_out = real + ".agentscope-edit-tmp-" + str(os.getpid()) is only unique per process. Two concurrent edit(...) calls on the same target file that are routed into the same sandbox container (same-key concurrent calls are a real scenario here — see PR #2846, which had to serialize them for the persisted-state path) share the PID namespace value only if they are the same process, but they do share the target path, so:
- both write to
<real>.agentscope-edit-tmp-<pid>with different PIDs → fine; - but with a forked/exec'd shell pool or a reused
shprocess (some sandbox backends run every command in one long-lived shell), both edits land on the sametmp_out: A writes content-A, B overwrites with content-B, A'sos.replacemoves the shared path (now content-B) onto the target, B'sos.replacethen fails withFileNotFoundErrorand falls into the in-place fallback — so A's edit is silently lost and reported as success ({"count": ...}).
The param dir right below already uses a UUID for exactly this reason; the same trick costs nothing here (e.g. uuid.uuid4().hex[:8], or tempfile.mkstemp(dir=os.path.dirname(real))). A count that is reported from a lost update is worse than a visible failure, because the agent believes the file is edited.
| if (contentBytes == null || contentBytes.length == 0) { | ||
| return EditResult.fail("Error: File '" + filePath + "' is empty"); | ||
| } | ||
| String content = new String(contentBytes, StandardCharsets.UTF_8); |
There was a problem hiding this comment.
[Info] The transfer fallback silently re-encodes non-UTF-8 text files, unlike the native path.
The Python script opens in binary and decode("utf-8")s inside a try, so a Latin-1 / GBK text file returns {"error": "read_failed"} and the edit fails cleanly. The fallback does new String(contentBytes, StandardCharsets.UTF_8), which replaces malformed byte sequences with U+FFFD, then re-uploads — so on a sandbox without python3, editing a legacy-encoding file succeeds while corrupting every non-ASCII byte outside the replaced region.
Extending FilesystemUtils to decode with CharsetDecoder.onMalformedInput(CodingErrorAction.REPORT) (surfacing read_failed-equivalent error) would keep the two paths honest to each other. This is pre-existing behavior on the Java side, but this PR is what makes the two paths observable alternatives for the same call, so it is the right time to align them.
| except Exception: | ||
| pass | ||
| try: | ||
| with open(real, "wb") as f: |
There was a problem hiding this comment.
[Info] In-place fallback can truncate the file on failure.
When the directory is not writable, the script re-opens the target with "wb" and writes — so an I/O error halfway (disk full, quota) leaves the file truncated rather than unchanged, and the {"count": ...} success is never reported. The os.replace path is atomic precisely to avoid this. Consider f.write(out_bytes); f.flush(); os.fsync(f.fileno()) at minimum, or a one-line note that the non-atomic fallback is best-effort. Not blocking — the primary path is atomic and the fallback matches the previous behavior of this method.
| out_bytes = out.encode("utf-8") | ||
| # realpath so os.replace writes through symlinks (replacing the link | ||
| # itself would break workspace symlinks) and stays on target's mount. | ||
| real = os.path.realpath(target) |
There was a problem hiding this comment.
[Info] Nice catch on realpath, one consequence worth documenting.
Resolving os.path.realpath(target) before writing is the right call — it edits through workspace symlinks instead of replacing them with a regular file, and keeps the temp file on the same mount so os.replace cannot cross devices. One side effect to pin in the javadoc: because os.stat(real)/os.chmod(tmp_out, st.st_mode) act on the resolved target, an edit that arrives via a symlink now mutates the referenced file's content while the symlink itself is preserved. That is the intended behavior; a test asserting "symlink still a symlink after edit" would lock it in (the LocalShellSandboxFilesystem tests cover plain files only).
325fdca to
352ec15
Compare
|
Addressed the four re-review findings at [Warning] Temp-file uniqueness — [Info] Non-atomic in-place fallback (chosen fix) — removed the [Info] Silent re-encode in fallback — the fallback now decodes with a strict UTF-8 [Info] Symlink test — added 109 tests passing. This also removes the last non-atomic write in the native path. |
|
@oss-maintainer please re-review Follow-up at
109 tests passing; rebased on current |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
The rework is a clear improvement: user content no longer travels inside the command line (paths only, shellQuoted), the Python helper is a static heredoc so it cannot be broken by `` sequences in the payload, the atomic same-directory temp + os.replace write with the read-only-directory fallback is the right shape, result parsing is now anchored on an explicit `RESULT` marker with exact-field error extraction, and the fallback decodes UTF-8 strictly instead of silently re-encoding a legacy-encoding file. Coverage on the fallback matrix is thorough. Holding at COMMENT for the symlink-semantics divergence between the two paths and one public-API compatibility question.
Findings
- [Warning]
BaseSandboxFilesystem.java:538- the transfer fallback re-uploads viauploadFiles(filePath, ...), which does not carry the native path's "writes through the symlink, never replaces it" guarantee; the two paths end up with different invariants for the same operation. - [Warning]
FilesystemUtils.java:72-performStringReplacementreturn type change is a source/binary-incompatible change for downstream callers of a published public utility; needs either a deprecated bridge or a migration-note entry. - [Info]
BaseSandboxFilesystem.java:337- cross-reference with #3269, which removes a quoted-space write command on these same backends; make sure the two are converging on one command-construction story.
Suggestions
- If the symlink asymmetry is intentional, one line in the
editViaTransferjavadoc plus a fake that distinguishes write-through from replace would make it explicit. - Consider asserting
EditResult.occurrences()parity between native and fallback paths for thereplaceAllcase in one parameterised test, so the two implementations cannot drift.
Notes
- CI is green (5/5 checks) on the current head and
license/clais signed; the two new commits are both from the same push, so this review covers the combined delta since my previous COMMENTED review.
Automated review by github-manager-bot
| if (start >= end) { | ||
| return 1; | ||
| } | ||
| try { |
There was a problem hiding this comment.
[Warning] The native path deliberately resolves realpath(target) and uses os.replace so a workspace symlink keeps pointing where it did (there is a test for it, edit_symlinkTarget_preservesLinkAndWritesThrough). This fallback does not have that property: it re-uploads through uploadFiles(filePath, ...), so on backends where upload creates/replaces the file rather than writing through the link, a sandbox that edited fine before python3 went missing can come back as a regular file - the same class of breakage the real dance above exists to avoid, just on the rarer path.
Two options: (1) note the asymmetry in the method javadoc and mirror it with a test that asserts the fallback's symlink behaviour on a fake that distinguishes the two, or (2) make the fallback preserve the link by downloading/uploading against the realpath instead of filePath. Worth deciding explicitly rather than leaving the two paths with different invariants.
| * {@link ReplacementResult#error(String)} on failure | ||
| */ | ||
| public static Object[] performStringReplacement( | ||
| public static ReplacementResult performStringReplacement( |
There was a problem hiding this comment.
[Warning] Replacing Object[] with a record is clearly the right API (the result.length == 1 discriminant was a foot-gun), but FilesystemUtils is a public utility of a published module, so this is a source- and binary-incompatible change for any extension or downstream subclass calling performStringReplacement(...) - callers get a NoSuchMethodError at runtime rather than a compile error. agentscope-extensions does not call it in-tree, which is why CI stays green.
Since the release is pre-2.0 this may be acceptable, but it should be a deliberate call: either keep the old signature as a @Deprecated bridge returning Object[], or list it in the migration/release notes alongside the SandboxState accessors removed in the sibling PR #2194. Same question for the BaseSandboxFilesystem protected surface if any of it changed shape.
| * @param runtimeContext per-call agent context; may be {@code null} | ||
| * @param filePath path of the file to edit | ||
| * @param oldString literal string to find (must be non-empty) | ||
| * @param newString replacement string; {@code null} is treated as the empty string |
There was a problem hiding this comment.
[Info] Cross-reference with PR #3269, which also edits this file and removes a quoted-space write command while surfacing its error output. Both changes are about how user-controlled strings reach the sandbox shell, so it is worth a sentence here (or there) saying whether the heredoc + shellQuoted-args form introduced here is meant to become the single command-construction path for this class, or whether #3269's write path stays as it is. That saves the next reader working out which of the two conventions applies where.
|
Follow-up on Two corrections to my review above, in case the diff view is confusing:
No new issues found in Automated review by github-manager-bot |
|
Re-checked against Two things in it are outright improvements: dropping the non-atomic That does not change my two The Automated review by github-manager-bot |
|
@oss-maintainer please re-review Follow-up at 1. Fallback symlink semantics (decided: document the divergence, option a)
2.
3. #3269 cross-reference — that PR removes a double-quoted span containing a space from 112 tests passing; PR description updated (test count and coverage list). |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
This PR rewrites BaseSandboxFilesystem.edit() to run a static inline Python script inside the sandbox for string replacement, with old/new crossing the boundary as files (paths only on the command line) and a download-edit-upload fallback when Python is unavailable or the native write fails. It also refactors FilesystemUtils.performStringReplacement() from an untyped Object[] to a type-safe ReplacementResult record and adapts LocalFilesystem/RemoteFilesystem accordingly. The change is a clear improvement over the previous python3 -c + hand-rolled JSON escaping approach and has thorough spy-based and real-shell test coverage. Overall: no critical issues, but a few actionable gaps around fallback completeness and cross-filesystem consistency remain.
Findings
- [Warning]
agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java:367—isPythonMissing()only treats exit code 127 and a few English substrings as "python3 missing"; exit code 126 (command found but not executable) is missed, so the transfer fallback is skipped in that case. - [Warning]
agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java:355—newString == nullis treated as deletion in the sandbox filesystem, butLocalFilesystem/RemoteFilesystemstill throw NPE on null, creating an inconsistent cross-filesystem API. - [Info]
agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java:431— Result JSON is parsed with hand-rolledparseErrorToken()/parseCount()although Jackson is available in the module; using a proper JSON parser would be more robust. - [Info]
agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java:357— The native edit path hardcodes/tmp/agentscope-edit-<uuid>as the staging directory, which loses the fast path on backends without a writable/tmp.
Suggestions
- Extend
isPythonMissing()to also recognize exit code 126 so a non-executablepython3degrades gracefully to the transfer path. - Apply the same
newString == nulldeletion semantics (or an explicit rejection) toLocalFilesystem.edit()andRemoteFilesystem.edit()so all filesystem implementations behave consistently. - Replace the hand-rolled JSON extraction with a small Jackson
ObjectMapperread of a result POJO; this removes the brittle substring parsing and makes the code easier to maintain. - Make the sandbox staging directory configurable or backend-derived instead of hardcoding
/tmp.
Automated review by github-manager-bot
| Map.entry(newPath, safeNew.getBytes(StandardCharsets.UTF_8)))); | ||
| if (params.size() < 2 || !params.get(0).isSuccess() || !params.get(1).isSuccess()) { | ||
| String err = "upload returned no response"; | ||
| for (FileUploadResponse r : params) { |
There was a problem hiding this comment.
isPythonMissing() only treats exit code 127 and a few English substrings as 'python3 missing'. Exit code 126 (command found but not executable) is not handled, so a sandbox where python3 exists but lacks execute permission will surface 'unexpected server response' instead of falling back to the transfer path. Add exitCode == 126 to the fallback condition.
|
|
||
| ExecuteResponse result = execute(runtimeContext, cmd, null); | ||
| String output = result.output() != null ? result.output().strip() : ""; | ||
| String tmpDir = "/tmp/agentscope-edit-" + UUID.randomUUID().toString().substring(0, 8); |
There was a problem hiding this comment.
BaseSandboxFilesystem.edit() now treats newString == null as deletion (empty replacement), but LocalFilesystem.edit() (line 370) and RemoteFilesystem.edit() (line 307) still throw NPE on a null newString because they pass it straight to String.replace()/FilesystemUtils.performStringReplacement(). Since this PR touches all three filesystem implementations, unify the null semantics or document that only the sandbox filesystem supports deletion via null.
| return EditResult.fail( | ||
| "Error editing file '" | ||
| + filePath | ||
| + "' (exitCode=" |
There was a problem hiding this comment.
mapNativeResult()/parseErrorToken()/parseCount() implement a hand-rolled JSON parser even though Jackson is already on the module classpath (e.g., HarnessSandboxJacksonModule). Using a small ObjectMapper or JsonParser would make the result parsing robust to field reordering, whitespace, and future error values that contain escaped quotes.
| String output = result.output() != null ? result.output().strip() : ""; | ||
| String tmpDir = "/tmp/agentscope-edit-" + UUID.randomUUID().toString().substring(0, 8); | ||
| String oldPath = tmpDir + "/old.bin"; | ||
| String newPath = tmpDir + "/new.bin"; |
There was a problem hiding this comment.
The native edit path hardcodes /tmp/agentscope-edit- as the parameter staging directory. Sandbox backends that do not expose a writable /tmp (or map /tmp differently) will silently lose the fast native path and always fall back to download-edit-upload. Consider making the staging prefix configurable or deriving it from the backend's workspace temp location.
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
This PR rewrites BaseSandboxFilesystem.edit() to run a static inline Python script inside the sandbox for string replacement, with old/new crossing the boundary as files (paths only on the command line) and a download-edit-upload fallback when Python is unavailable or the native write fails. It also refactors FilesystemUtils.performStringReplacement() from an untyped Object[] to a type-safe ReplacementResult record and adapts LocalFilesystem/RemoteFilesystem accordingly. The change is a clear improvement over the previous python3 -c + hand-rolled JSON escaping approach and has thorough spy-based and real-shell test coverage. Overall: no critical issues, but a few actionable gaps around fallback completeness and cross-filesystem consistency remain.
Findings
- [Warning]
agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java:367—isPythonMissing()only treats exit code 127 and a few English substrings as "python3 missing"; exit code 126 (command found but not executable) is missed, so the transfer fallback is skipped in that case. - [Warning]
agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java:355—newString == nullis treated as deletion in the sandbox filesystem, butLocalFilesystem/RemoteFilesystemstill throw NPE on null, creating an inconsistent cross-filesystem API. - [Info]
agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java:431— Result JSON is parsed with hand-rolledparseErrorToken()/parseCount()although Jackson is available in the module; using a proper JSON parser would be more robust. - [Info]
agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java:357— The native edit path hardcodes/tmp/agentscope-edit-<uuid>as the staging directory, which loses the fast path on backends without a writable/tmp.
Suggestions
- Extend
isPythonMissing()to also recognize exit code 126 so a non-executablepython3degrades gracefully to the transfer path. - Apply the same
newString == nulldeletion semantics (or an explicit rejection) toLocalFilesystem.edit()andRemoteFilesystem.edit()so all filesystem implementations behave consistently. - Replace the hand-rolled JSON extraction with a small Jackson
ObjectMapperread of a result POJO; this removes the brittle substring parsing and makes the code easier to maintain. - Make the sandbox staging directory configurable or backend-derived instead of hardcoding
/tmp.
Automated review by github-manager-bot
| Map.entry(newPath, safeNew.getBytes(StandardCharsets.UTF_8)))); | ||
| if (params.size() < 2 || !params.get(0).isSuccess() || !params.get(1).isSuccess()) { | ||
| String err = "upload returned no response"; | ||
| for (FileUploadResponse r : params) { |
There was a problem hiding this comment.
isPythonMissing() only treats exit code 127 and a few English substrings as 'python3 missing'. Exit code 126 (command found but not executable) is not handled, so a sandbox where python3 exists but lacks execute permission will surface 'unexpected server response' instead of falling back to the transfer path. Add exitCode == 126 to the fallback condition.
|
|
||
| ExecuteResponse result = execute(runtimeContext, cmd, null); | ||
| String output = result.output() != null ? result.output().strip() : ""; | ||
| String tmpDir = "/tmp/agentscope-edit-" + UUID.randomUUID().toString().substring(0, 8); |
There was a problem hiding this comment.
BaseSandboxFilesystem.edit() now treats newString == null as deletion (empty replacement), but LocalFilesystem.edit() (line 370) and RemoteFilesystem.edit() (line 307) still throw NPE on a null newString because they pass it straight to String.replace()/FilesystemUtils.performStringReplacement(). Since this PR touches all three filesystem implementations, unify the null semantics or document that only the sandbox filesystem supports deletion via null.
| return EditResult.fail( | ||
| "Error editing file '" | ||
| + filePath | ||
| + "' (exitCode=" |
There was a problem hiding this comment.
mapNativeResult()/parseErrorToken()/parseCount() implement a hand-rolled JSON parser even though Jackson is already on the module classpath (e.g., HarnessSandboxJacksonModule). Using a small ObjectMapper or JsonParser would make the result parsing robust to field reordering, whitespace, and future error values that contain escaped quotes.
| String output = result.output() != null ? result.output().strip() : ""; | ||
| String tmpDir = "/tmp/agentscope-edit-" + UUID.randomUUID().toString().substring(0, 8); | ||
| String oldPath = tmpDir + "/old.bin"; | ||
| String newPath = tmpDir + "/new.bin"; |
There was a problem hiding this comment.
The native edit path hardcodes /tmp/agentscope-edit- as the parameter staging directory. Sandbox backends that do not expose a writable /tmp (or map /tmp differently) will silently lose the fast native path and always fall back to download-edit-upload. Consider making the staging prefix configurable or deriving it from the backend's workspace temp location.
6991b98 to
7040a75
Compare
|
@oss-maintainer please re-review Follow-up at 1. [Warning] exit 126 — fixed. 2. [Warning] 3. [Info] Jackson parsing — done. 4. [Info] hardcoded 125 tests in the filesystem suites, 1090 in the module, all passing; rebased on current |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review at 7040a75a. This round moves the sandbox edit onto a static heredoc script (old/new cross the boundary as files, only paths on the command line), adds strict UTF-8 decoding, a JSON-parsed result channel instead of substring sniffing, and a transfer fallback when python3 is unavailable or the atomic write fails. The previous findings are addressed — the payload no longer flows through the command string, result parsing is robust against user-controlled text, and all three filesystem backends now share one argument validator. Tests are extensive (1053-line extension of BaseSandboxFilesystemTest). CLA signed; builds green on ubuntu/windows.
Findings
- [Warning] BaseSandboxFilesystem.java:372 — the staging path is hard-coded to
/tmp, outside the default sandbox transfer root (/workspace) for the Docker/K8s backends: the native path would never be used in production sandboxes, silently keeping the fallback as the default. - [Warning] FilesystemUtils.java:52 — public
performStringReplacementreturn type changed with no bridge; source/binary break for downstream users of the published artifact. - [Info] BaseSandboxFilesystem.java:372 — 32 bits of UUID entropy for a shared
/tmpstaging dir, and the trailingrm -rfcan clobber a concurrent edit's payload. - [Info] BaseSandboxFilesystem.java:405 — constant heredoc delimiter with an agent-controlled path lets a path containing
__AGENTSCOPE_EDIT_PY__end the heredoc early. - [Info] FilesystemUtils.java:79 — empty
oldStringis reported as "string not found" rather than a validation error.
The staging-path point is the one that affects whether this change delivers its goal; the rest are non-blocking.
Automated review by github-manager-bot
|
|
||
| ExecuteResponse result = execute(runtimeContext, cmd, null); | ||
| String output = result.output() != null ? result.output().strip() : ""; | ||
| String tmpDir = "/tmp/agentscope-edit-" + UUID.randomUUID().toString().substring(0, 8); |
There was a problem hiding this comment.
/tmp/agentscope-edit-... is outside the sandbox transfer root used by the Docker/Kubernetes backends (/workspace by default), so the two param uploads are rejected there and every edit silently degrades to the download/replace/re-upload fallback even when python3 is available — the native path never runs in exactly the environments it was written for. Please stage the old/new files under the same root uploadFiles accepts (or probe the root), and emit a log line when the native path is skipped.
|
|
||
| ExecuteResponse result = execute(runtimeContext, cmd, null); | ||
| String output = result.output() != null ? result.output().strip() : ""; | ||
| String tmpDir = "/tmp/agentscope-edit-" + UUID.randomUUID().toString().substring(0, 8); |
There was a problem hiding this comment.
8 hex characters (32 bits) of UUID are weak if two concurrent edits ever share a sandbox, and the trailing rm -rf of the staging directory can delete the peer's still-unexecuted payload. uuid.randomUUID().toString() costs nothing and removes the collision class.
| + " " | ||
| + (replaceAll ? "true" : "false") | ||
| + " <<'" | ||
| + EDIT_HEREDOC_DELIMITER |
There was a problem hiding this comment.
The heredoc delimiter is a constant while filePath is agent-controlled: a path containing __AGENTSCOPE_EDIT_PY__ at line start terminates the heredoc early and the remainder of the path becomes shell input. shellQuote covers the arguments but not the body — deriving a per-call delimiter (as the unique tmp dir already does) would close it.
| } | ||
|
|
||
| /** Result of a string replacement operation. */ | ||
| public record ReplacementResult(String content, int occurrences, String error) { |
There was a problem hiding this comment.
performStringReplacement is a public method of the published harness artifact and its return type changed from Object[] to ReplacementResult with no bridge — source- and binary-incompatible for downstream callers. The javadoc notes this is intentional; please make sure it is called out in the release notes / changelog so the change is not a surprise.
| * {@code null} to signal that the edit may proceed | ||
| */ | ||
| public static EditResult validateEditArguments(String filePath, String oldStr, String newStr) { | ||
| if (oldStr == null || oldStr.isEmpty()) { |
There was a problem hiding this comment.
An empty oldString is rejected with "String not found in file", which reads as a content error rather than a validation error, and differs from the sandbox script's own old == "" path. A dedicated message ("oldString must not be empty") would help the model self-correct.
Replace the download-edit-upload path of BaseSandboxFilesystem.edit()
with a native sandbox edit: old/new cross the boundary as files and the
command string carries paths only, so user content never enters shell
quoting. The script is a static inline heredoc (real newlines, quoted
delimiter) with a single __RESULT__{json} output line parsed by exact
"error" field extraction.
Details:
- python exit status survives the trailing rm via $?; exit passthrough
so python3-missing detection works on backends that do not relay stderr
(e.g. Daytona) instead of relying on a masked exit code
- write-back uses realpath + same-directory tmp + os.replace so it stays
on target's mount, follows symlinks, preserves mode, and falls back to
in-place write when the directory is not writable
- param upload failure, execute exception, and python3-missing all
degrade to the existing download-edit-upload path
- FilesystemUtils.ReplacementResult replaces the type-unsafe Object[]
- remove jsonEscape() and the hard python3 inline -c invocation
Tests: 62 passing (canned unit + real sh/python3 integration), covering
multiline/special-character params kept out of the command string, exact
error mapping, exit-code passthrough, and every fallback branch.
…te errors Address re-review at 325fdca. - tmp file name now uses uuid4().hex instead of os.getpid(): a long-lived shell process can reuse the pid, so two concurrent edits of the same target could share a temp file and one update could be silently lost while still reporting count success. - remove the non-atomic in-place "wb" write for read-only directories: opening with O_TRUNC truncated the target before writing, so a mid-write failure destroyed data. A write failure now happens only before the target is touched, and edit() retries such write_failed results through editViaTransfer, whose shell redirection needs only file write permission and succeeds where a read-only directory blocked the temp. - fallback decodes with a strict UTF-8 decoder (REPORT) instead of new String(bytes, UTF_8): a legacy-encoding file is now rejected rather than silently byte-substituted and re-encoded. - add an integration test asserting a symlink survives an edit and the content is written through to its target. Tests: 109 passing.
The shutil.copyfile branch that handled os.replace failure was the same truncate-on-failure risk as the in-place "wb" write removed earlier: copyfile opens the destination with O_TRUNC and rewrites it, so a mid-write failure destroys the target. Since the temp file lives in the target's own directory, os.replace has no cross-device failure mode left; when it still fails (immutable/NFS), the script now returns write_failed with the target untouched and Java retries through the transfer path. Also document the edit() contract: editing a symlink writes through to the resolved target and preserves the link itself; write_failed implies the file is unchanged and is retried via transfer.
…hange Address the re-review at 648fe7b: - editViaTransfer javadoc now states the documented divergence: it re-uploads against filePath as given, so symlink preservation depends on the backend's upload semantics (write-through keeps the link, replace breaks it), unlike the native path which guarantees it. - add fallback tests distinguishing the two upload semantics (write-through preserves the symlink, replacing breaks it) and an occurrences-parity test running replaceAll through both native and fallback paths so the implementations cannot drift. - FilesystemUtils.performStringReplacement gets an @APinote recording the Object[] -> ReplacementResult change as an internal API (all four callers live in agentscope-harness); no deprecated bridge. Tests: 112 passing.
The project's javadoc plugin does not register the @APinote tag (it is only standard for JDK's own documentation), so maven-javadoc-plugin failed with 'unknown tag: apiNote' on both CI platforms. Use a plain paragraph instead; the migration note content is unchanged.
…alidation Address the re-review on c55d57f. - isPythonMissing() now also treats exit code 126 (command found but not executable, e.g. python3 present with the execute bit cleared) and permission-denied output as a missing interpreter, so the transfer fallback runs instead of surfacing 'unexpected server response'. - edit() argument validation is shared via FilesystemUtils.validateEditArguments() and applied in LocalFilesystem, RemoteFilesystem and BaseSandboxFilesystem alike: a null newString is rejected (previously an NPE in the local/remote implementations and a silent deletion in the sandbox one) — deletion is expressed by passing "", which keeps the normal replacement semantics and occurrence count — and an empty oldString is rejected because it makes countOccurrences loop forever. - the __RESULT__ payload is parsed with Jackson instead of hand-rolled substring extraction, removing the brittle parseErrorToken/parseCount helpers; unparseable payloads are logged and reported as-is. Tests: 125 in the filesystem suites, 1090 in the module.
…trip Replace the staging-directory transport in BaseSandboxFilesystem.edit() with base64-encoded command-line arguments, and keep a deprecated bridge for the published performStringReplacement signature. - old/new now travel base64-encoded as argv. The base64 alphabet contains no shell metacharacters, so the payloads cannot break out of their single-quoted argument, and the file itself never crosses the boundary. This removes the two-file /tmp staging upload, its cleanup command, the concurrent-cleanup race, and the dependency on uploadFiles supporting paths outside the sandbox workspace — the latter is what made the native path unreachable on the Docker/Kubernetes backends, whose transfer APIs refuse any path outside the workspace root. - the whole command string is bounded by MAX_INLINE_COMMAND_BYTES (112 KiB). The command is passed to the backend as a single argv element, so the kernel MAX_ARG_STRLEN (128 KiB, measured E2BIG on this platform) applies to it in total: the script body, the target path and both payloads share one budget. Oversized arguments are rejected with a "split the edit" diagnostic rather than silently transferred; old/new are edit fragments, so a request this size should be split. - performStringReplacement is @deprecated and keeps its Object[] shape as a source/binary bridge: agentscope-harness ships to Sonatype and the method existed with that signature in v2.0.1-v2.0.3. The typed form is now stringReplacement, used by all three filesystem implementations. - an empty oldString now reports a validation error instead of "String not found", and is rejected everywhere (it used to spin forever in countOccurrences on the local and remote filesystems). Tests: 138 in the filesystem suites, 1103 in the module. Adds bridge-contract coverage, oversized-argument rejection, and a test that a path containing the heredoc delimiter cannot truncate the command (it cannot: a newline inside single quotes stays inside the argument, and the heredoc body is read after the command line is complete).
7040a75 to
929828e
Compare
|
@oss-maintainer please re-review Follow-up at 1. [Warning] I traced why before changing anything. Rather than widen that boundary (which would have needed per-backend changes, an exec fallback with its own size cap, and a snapshot-pollution argument about workspace staging), the params now travel base64-encoded in the command:
2. Size bound is on the whole command, not per parameter (your earlier point, which I had wrong). The command is a single argv element, so the kernel 3. [Warning] 4. Other items from your review
138 tests in the filesystem suites, 1103 in the module; rebased on current |
Why the approach changed during reviewSince the head now differs from the first reviewed version by a design decision rather than a refactor, recording the reasoning so it does not have to be re-derived. (Also added to the description.) The first revision staged
Options considered, and why the chosen one:
Two consequences, both deliberate and worth your scrutiny:
One process note for my own record: I initially proposed bounding |
合并 PR agentscope-ai#2398: fix(harness): run native inline-python edit in sandbox with transfer fallback(7 个提交,4a0e6a59..929828e)。 sandbox edit 改为在沙箱内跑内联 python 脚本,old/new 以 base64 走 argv,命令串 只带路径,用户内容不进入 shell 引用;python3 缺失/不可执行、参数超限、上传失败 等情况回退到 download-edit-upload。edit 参数校验统一到 FilesystemUtils.validateEditArguments,替换结果改由 stringReplacement 返回 ReplacementResult,performStringReplacement(Object[]) 保留为 @deprecated 二进制兼容桥接。 冲突处理: - FilesystemUtils/LocalFilesystem/RemoteFilesystem/BaseSandboxFilesystem 取 PR 版本;develop 侧的 ReplacementResult 概念已被 PR 覆盖,workspace-root 统一 API 适配在两侧一致,无本地实现丢失 - BaseSandboxFilesystemTest 取 PR 版本,develop 独有的 getWorkspaceRoot_defaultsToWorkspace 迁回(其余 develop 侧 edit_* 测试针对的 是被本次合并替换掉的 download-edit-upload 实现) - 补入 FilesystemUtilsTest(新增) agentscope-harness 1143 个测试通过,spotless 通过。
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
PR #2398 rewrites BaseSandboxFilesystem.edit() to run a static inline-Python edit natively inside the sandbox (old/new crossing as base64-encoded command-line arguments, heredoc script, Jackson-parsed RESULT line) with a download → stringReplacement → re-upload fallback, plus a typed ReplacementResult refactor across all three filesystems. Follow-up commit 929828e genuinely closes all three earlier findings: the /tmp staging is removed at the root rather than relocated (verified against DockerSandbox.requireTransferPath and resolveArchivePath, which would have rejected it — the native path now performs zero file transfers), exit 126 now triggers the transfer fallback alongside 127, and performStringReplacement keeps its exact published Object[] signature as a @deprecated bridge; I additionally verified the generated command end-to-end with real sh/python3 (simple/special-char edits, symlink write-through with the link preserved, exit-code passthrough, and no heredoc break-out from a delimiter-shaped or newline-bearing path). Remaining issues are minor: a stale class-level javadoc still asserting the old "command line carries paths only" property, os.stat outside the script's try (a TOCTOU race yields a raw traceback instead of a mapped error, though the atomic temp+os.replace write itself is sound), the transfer fallback mislabeling any download failure as "file not found", and a CRLF-normalization divergence that undercuts the claimed three-way parity.
Re-reviewed at head 929828e2e7; CLA: unknown.
Automated review by github-manager-bot
| boolean replaceAll) { | ||
| List<FileDownloadResponse> downloaded = downloadFiles(runtimeContext, List.of(filePath)); | ||
| if (downloaded.isEmpty() || !downloaded.get(0).isSuccess()) { | ||
| return EditResult.fail("Error: File '" + filePath + "' not found"); |
There was a problem hiding this comment.
editViaTransfer reports "Error: File 'X' not found" for any downloadFiles failure, discarding the real reason the response carries. In the write_failed → fallback flow the file provably exists (the native script just read and edited it), so a transient transfer failure — backend 504 / exec error / truncated output, all of which SandboxBackedFilesystem.downloadFiles reports via FileDownloadResponse.fail(path, actualMessage) — is misdiagnosed as the file being absent. An agent that trusts that message may abandon the edit or try to recreate the file. The upload branch ~30 lines below surfaces its error ("Error writing edited file ...: " + err); mirror it using downloaded.get(0).error(), keeping "not found" only for downloaded.isEmpty() if that distinction is wanted.
| * commands. Read uses server-side commands for paginated access. Write delegates content | ||
| * transfer to {@link #uploadFiles}. Edit uses server-side commands for string replacement. | ||
| * transfer to {@link #uploadFiles}. Edit runs a static inline Python script inside the sandbox | ||
| * ({@code old}/{@code new} cross the boundary as files, the command line carries paths only), |
There was a problem hiding this comment.
The class-level javadoc still describes the removed staging design: "old/new cross the boundary as files, the command line carries paths only". The head actually sends them base64-encoded on the command line (nativeEditCommand), which the PR description itself flags as a deliberate weakening of exactly this property. Since the whole review history of this PR turned on the "user content never enters the command string" claim, leaving the old (now false) assertion here will mislead the next auditor of the injection surface. One-line fix: align it with the accurate method-level javadoc ("base64-encoded on the command line; the alphabet has no shell metacharacters").
| # uuid4 keeps the temp name unique per edit even when concurrent edits | ||
| # share a long-lived shell process (os.getpid() would not be unique). | ||
| real = os.path.realpath(target) | ||
| st = os.stat(real) |
There was a problem hiding this comment.
TOCTOU gap in the script: st = os.stat(real) sits outside the try that guards the write. If the resolved target is unlinked (or its permissions change) between the open(target, "rb") read and this stat — e.g. a concurrent command in the same sandbox — the script dies with an uncaught traceback: no __RESULT__ line, exit 1, and Java surfaces "unexpected server response: Traceback..." instead of a mapped read_failed/write_failed, and skips the transfer retry. Nothing is corrupted (no write has happened yet), so this is diagnostics-only, but moving the realpath/stat/tmp_out block inside the try and reporting write_failed keeps every failure on the designed result channel. Note the atomic temp+os.replace write itself is sound — on failure the target is untouched, verified live.
| Object[] result = | ||
| FilesystemUtils.performStringReplacement( | ||
| FilesystemUtils.ReplacementResult result = | ||
| FilesystemUtils.stringReplacement( |
There was a problem hiding this comment.
The PR claims "Replacement semantics match FilesystemUtils.stringReplacement exactly … so all three filesystems behave identically", but LocalFilesystem still normalizes \r\n→\n for content, old and new (and writes the whole file back with LF endings), while the sandbox script and RemoteFilesystem do exact-byte matching. On a CRLF file, an LF-style oldString succeeds locally — silently converting every remaining CRLF to LF — but returns string_not_found on the sandbox path (verified: the inline script matches old as exact bytes). This is the same class of cross-filesystem inconsistency the PR fixed for null newString via the shared validateEditArguments; either normalize in all three implementations or document the divergence.
Addresses the four minor findings from the re-review at 929828e. - FilesystemUtils.stringReplacement now normalizes CRLF and lone CR to LF on the content and on both patterns. Line-ending handling lived only in LocalFilesystem, so the same edit matched on the local filesystem and silently did not match on the remote and sandbox ones. Normalizing inside the shared utility makes all three agree, and writing back LF-only matches LocalFilesystem's long-standing behaviour (CRLF is a defect on a Unix-style filesystem). LocalFilesystem drops its now-redundant pre-normalization. - The sandbox script stats the target inside the write try block. A target vanishing between the read and the stat raised an uncaught OSError, printing no __RESULT__ line, so Java reported "unexpected server response" instead of a mapped error. - The transfer fallback no longer reports every download failure as "File not found": a permission or transport failure now says the file could not be read and carries the underlying cause, so the caller is not sent off to re-read a file it already has. - Drop the stale class-level javadoc claim that the command line carries paths only; the params now travel base64-encoded. Tests: 147 in the filesystem suites, 1112 in the module. Adds CRLF/LF/lone-CR matching, LF-only write-back, occurrence-guard parity across line endings, and fallback download-failure messaging.
|
@oss-maintainer please re-review Follow-up at 1. Stale class javadoc — fixed. The class-level text still claimed the command line carries paths only; it now describes the base64-encoded params and points at 2. 3. Transfer fallback mislabeled every download failure as file_not_found — fixed. It now distinguishes them: a backend-side read failure reports 4. CRLF divergence, and the claimed three-way parity — fixed, by unifying rather than by softening the claim. 147 tests in the filesystem suites, 1112 in the module; |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Commit d545663 unifies LF normalization by moving it into FilesystemUtils.stringReplacement, closes the os.stat TOCTOU gap by moving the stat inside the Python script's write try-block, fixes the transfer-fallback error to report the real download failure cause, and updates the stale class-level javadoc; the prior review's four findings are therefore fully resolved except for the CRLF parity item, which is only half-fixed. The sandbox native Python path still performs exact-byte replacement, while the fallback now normalizes via FilesystemUtils.stringReplacement, so the same edit can succeed or fail depending on whether python3 is available. This reintroduces an inconsistency within BaseSandboxFilesystem itself and leaves the claimed three-way filesystem parity incomplete.
Automated review by github-manager-bot
| return EditResult.fail("Error: oldString must not be null or empty"); | ||
| } | ||
| if (newStr == null) { | ||
| return EditResult.fail( |
There was a problem hiding this comment.
The moved line-ending normalization makes FilesystemUtils.stringReplacement LF-normalize content and both patterns, but the sandbox native Python script (unchanged in this diff) still performs exact-byte replacement. That creates a new native-vs-fallback divergence inside BaseSandboxFilesystem: an edit of a CRLF file with an LF pattern succeeds via the python3-missing fallback but fails on the native path. To fully unify semantics, the Python helper should apply the same CRLF/lone-CR→LF normalization before matching and writing, or the fallback should be kept exact-byte to match the native path.
Resolves the conflict introduced by agentscope-ai#3226, which rewrote the same edit() method on the same class. Our implementation supersedes it, so ours is kept; agentscope-ai#3226's substance is preserved as follows. - agentscope-ai#3226's three canned tests are carried over. The one asserting python3 -c was retargeted at the heredoc form: the real invariant behind agentscope-ai#2571 is that program statements are separated by real line feeds, not that the two-character sequence backslash-n never appears (the CRLF normalizer legitimately uses it inside Python string literals). Its two executeFailure tests now pass because edit() reports execution-layer failures through the same executeFailureMessage() convention as ls/read/grep/glob, which edit() was the last method not to use. - agentscope-ai#3226's three integration tests are behaviourally identical to the edit_simpleReplacement / edit_multipleOccurrencesWithoutReplaceAll / edit_replaceAll tests already here; the one assertion they added (a rejected ambiguous edit leaves the file untouched) is folded into ours. Also closes the remaining half of the CRLF parity finding: the native path performed byte-exact replacement while the transfer fallback normalized, so the same edit could succeed or fail depending on whether python3 was present. The script now applies the same LF normalization as FilesystemUtils.stringReplacement. The escapes in that snippet are doubled because a Java text block would otherwise emit real CR/LF bytes and split the Python string literal across lines.
|
@oss-maintainer please re-review
1. Conflict with #3226. It rewrote the same
2. CRLF parity — you were right that it was only half-fixed. The native script still replaced bytes exactly while the fallback normalized, so the same edit could succeed with One implementation note, since it is easy to trip over again: the normalization snippet inside the Java text block has to escape as 156 tests in the filesystem suites, 1118 in the module; |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review of 02797dfe (responding to your request). The conflict with #3226 is resolved correctly and the CRLF parity finding is closed: the program is now fed through python3 - with a quoted heredoc so statements are separated by real line feeds, user content only reaches the sandbox as base64 arguments, no literal \n is emitted into the generated program, and the retargeted assertions pin the real invariant instead of the over-broad assertFalse(cmd.contains("\\n")). The no-marker failure path also now reports through executeFailureMessage(...), consistent with ls/read. One test-coverage gap remains, so this is a comment rather than a blocking change request.
Findings
- [Warning]
BaseSandboxFilesystemTest.java:252— the new 98 lines cover CRLF parity, failure reporting and one write-failed fallback case, but nothing pins the heredoc-quoting edges (single quotes / backticks in the path, trailing-newline content).
Suggestions
The file path is the one remaining piece of user input that still goes through shell quoting here — a case with ' or a backtick in the path, plus a content case ending in a newline, would lock in the command construction you just reworked. Apart from that, thanks for keeping #3226's canned tests and re-targeting them rather than dropping them.
Automated review by github-manager-bot
Additional notes (not anchored to a changed line)
- [WARNING]
agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java:252— The 98 new test lines verify CRLF parity, executeFailure reporting, and one write_failed fallback case, but do not add explicit tests for heredoc-quoting edge cases. Add tests for a file path containing single quotes (the only user input still shell-quoted), backticks, and a trailing-newline pattern in file content to ensure thepython3 - <<'...'command construction stays robust. (line outside diff)
The target path is the last piece of user input that still passes through shell quoting (old/new are base64), so pin the edges that a quoting mistake would break: - a path containing a single quote, alongside a decoy file one substitution away, asserting the edit lands on the intended file and not the decoy; - a path containing backticks, asserting no command substitution runs; - old/new patterns ending in a newline, which would split the command into extra lines if the encoding did not protect them. Each asserts the resulting file content rather than only the EditResult.
No conflict this time: upstream's a2cbea5 touches SandboxBackedFilesystem and its test, which do not overlap with the edit() path on this branch.
|
@oss-maintainer please re-review
Heredoc quoting edges. The target path is the last piece of user input that still passes through shell quoting, since
Each asserts the resulting file content, not just the 159 tests in the filesystem suites, 1303 in the module; |
Take the merged agentscope-ai#2196 splits as authoritative for e2b files: [1/3] hardened envd streaming parsing, [2/3] Filesystem REST transfer, [3/3] SandboxFileTransfer routing. Local-only behavior preserved on top: - e2b exec server-side kill (Connect-Timeout-Ms, readTimeout(0), interrupt-bit handling); timeout<=0 keeps the historical no-client-timeout path used by the opt-in live-sandbox test - E2bSandbox volume support (early-return archiving, volumeMounts create param) adapted to the new tar/transfer helpers with WorkspaceSpec-root lookups (local state has no getWorkspaceRoot) - BaseSandboxFilesystem native edit + transfer fallback (local agentscope-ai#2398); dropped upstream python-first edit and its tests - FilesystemTool norm() follows upstream namespace-aware normalize(path, ctx); the local absolute-path skip is retired (verified unnecessary under the new architecture) - WorkspaceContextMiddleware local prompt tests moved to the WorkspaceContextMaterials registry (JEV redesign)
Buktal
left a comment
There was a problem hiding this comment.
docs/v2/en/docs/harness/sandbox.md:208 and docs/v2/zh/docs/harness/sandbox.md:207 still list python3 as a required image-contract item for edit_file. This PR deliberately makes it optional — a missing or non-executable python3 now degrades edit_file to the transfer path — so both rows now mislead image selection. Please update both locales: mark python3 as optional for edit_file (native fast path / transfer fallback), and while there record the other user-visible changes this PR introduces — null newString and empty oldString are now rejected uniformly by all three filesystems, and an edit rewrites CRLF/lone-CR content back with LF endings on the remote and sandbox paths (previously exact-byte there). The contribution workflow requires matching docs/v2 EN+ZH updates for user-visible behavior changes; this PR currently contains none.
FilesystemUtils.performStringReplacement (agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/util/FilesystemUtils.java:101) keeps the published v2.0.x Object[] signature but now silently LF-normalizes the content and both patterns by delegating to stringReplacement (FilesystemUtils.java:125-127), where v2.0.1–v2.0.3 did exact-byte matching. Its @Deprecated javadoc states stringReplacement "returns the same result" (FilesystemUtils.java:96), which is no longer true relative to the versions the bridge shipped in. Either restore exact-byte semantics inside the bridge, or state the normalization explicitly in the bridge javadoc and in the release notes.
Non-blocking notes:
decodeUtf8Strict(BaseSandboxFilesystem.java:553) duplicates the strict UTF-8 decoder inRemoteFilesystem.java:591-597; a single shared implementation inFilesystemUtilswould serve both.- Public
FilesystemUtils.stringReplacementstill loops forever on an emptyoldString(viacountOccurrences,FilesystemUtils.java:168); the guard lives only invalidateEditArguments, so a one-line guard inside the utility would protect any future caller. validateEditArgumentsnever reads itsfilePathparameter (FilesystemUtils.java:78).- A hard kill between temp-file creation and
os.replaceleaves.agentscope-edit-tmp-<uuid>files in the workspace (BaseSandboxFilesystem.java:301) — visible tols/globand captured by snapshots. A stale-file sweep on edit, or a documented note, would close it.
Address the latest review on b4c3704. - Update the EN/ZH sandbox image contract: python3 is optional for edit_file (native fast path, transfer fallback); document the 112 KiB whole-command limit. Document null/empty edit arguments and LF normalization in both filesystem guides. - Keep the published deprecated performStringReplacement bridge exact with respect to line endings, as it was in v2.0.x. The new typed stringReplacement API normalizes CRLF/lone-CR to LF. Correct the bridge Javadoc and add a regression test proving the two contracts differ intentionally. - Guard empty oldString in stringReplacement itself, and make the public countOccurrences helper return zero for an empty needle rather than looping. - Remove the unused filePath parameter from validateEditArguments. - Centralize strict UTF-8 decoding shared by sandbox fallback and RemoteFilesystem. - Document that SIGKILL between temp creation and rename can leave a uniquely named sibling temp file; do not age-sweep it because that can race with a concurrent edit.
Resolve the latest review findings on PR agentscope-ai#2398 while integrating the current upstream main. - Update the EN/ZH sandbox contract: python3 is optional for edit_file (native fast path, transfer fallback), document the whole-command 112 KiB limit, and explain the fallback and user-visible edit semantics. - Preserve the published performStringReplacement Object[] bridge's exact line-ending matching behavior. The new typed stringReplacement method normalizes CRLF/lone-CR to LF; a regression test makes this intentional distinction explicit. - Reject empty oldString inside stringReplacement itself; make the public countOccurrences helper return zero for an empty needle rather than loop. - Remove the unused filePath validator parameter and centralize strict UTF-8 decoding shared by sandbox and remote implementations. - Document that a hard kill during atomic edit can leave a uniquely named sibling temp file; do not age-sweep because that could race with an active concurrent edit. Verification: 164 filesystem tests pass; agentscope-harness 1363 pass, 0 fail, 10 skipped; docs tests/validate/broken-links and javadoc:jar pass.
Preview approval requiredThis pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit |
|
@Buktal please re-review Follow-up at
Checks: 164 filesystem tests pass; |
|
@Buktal please re-review Follow-up at 1. EN/ZH docs updated. 2. Deprecated bridge preserves the published semantics, not only the signature. 3. Shared strict UTF-8 decoder. Extracted 4. Empty 5. Temp-file hard-kill caveat documented. A SIGKILL between creation and Checks: 164 filesystem tests pass; |
Summary
Rewrite
BaseSandboxFilesystem.edit()to perform the string replacement natively inside the sandbox using a static inline Python script, instead of downloading the whole file to Java, replacing, and uploading it back.old/newcross the boundary base64-encoded on the command line — the base64 alphabet has no shell metacharacters, so user content cannot break out of its argument, and the file itself never crosses the boundary. The script is a fixed heredoc (real newlines, quoted delimiter) whose only output is one__RESULT__{json}line, parsed with Jackson.When
python3is unavailable — or the command would exceed the size bound, orexecutethrows, or the native write fails (write_failed, target untouched) —edit()degrades to the existing download →FilesystemUtils.stringReplacement()→ upload path.Why the design changed on the way to review
Worth recording, because the head differs from the first reviewed version by more than a refactor. The first revision staged
old/newas files under/tmp/agentscope-edit-<uuid>/and uploaded them viauploadFiles(). That looked reasonable until the transfer path was traced:uploadFilesrefuses any path outside the sandbox workspace —resolveArchivePaththrows for the tar-hydrate fallback,DockerSandbox.requireTransferPathrequires the path underworkspaceRoot, and on Kubernetes the runtime itself confines/upload//downloadtoWORKSPACE_DIR(agentscope-runtime,sandbox/box/shared/routers/workspace.py,ensure_within_workspace, HTTP 403 outside).edit()fell back to the transfer path on every call, and the native path this PR exists to provide never ran in production. Opening/tmpon the backends would not have helped — the boundary is enforced server-side.Rather than widen that boundary (per-backend changes, an exec fallback with its own size cap, and workspace staging that would leak user content into workspace snapshots), the params now travel base64-encoded in the command. That removes the staging round trip entirely and makes the native path portable across backends. Two consequences are deliberate:
The deprecated
performStringReplacement()bridge deliberately retains the published v2.0.x exact-line-ending behavior; only the new typedstringReplacement()normalizes CRLF/lone-CR to LF. This avoids silently changing semantics for downstream users of the old API while allowing the filesystem implementations to converge on the new behavior.Bugs Fixed (#2397)
\nnot recognized bypython3 -c— the old inline script embedded Java\\nliterally, causingSyntaxError. The new script is a text block with real newlines and a quoted heredoc delimiter.jsonEscape()incomplete — the hand-built JSON missed control characters, so adetailstring could breakjson.loads(). The result payload is now produced byjson.dumpsinside the script and parsed with Jackson; user content never takes part in building it.python3dependency — removed as a hard requirement: missingpython3falls back to the transfer path.Why native edit
old/newride in the command as base64, so there is no/tmpdirectory to create, clean up, or race on, and no dependency onuploadFilesaccepting paths outside the sandbox workspace (the Docker/Kubernetes transfer APIs refuse those, which is what kept the native path unreachable).MAX_ARG_STRLENapplies to it in total. Oversized arguments are rejected with a "split the edit" diagnostic rather than silently transferred —old/neware edit fragments, so a request that large should be split.FilesystemUtils.stringReplacement): same occurrence counting, same error messages, and the same CRLF/lone-CR → LF normalization — applied identically in the native script and in the fallback, so an edit can neither match on one backend and not the other, nor succeed on one path and fail on the other withinBaseSandboxFilesystem. Line endings are written back as LF, matchingLocalFilesystem's long-standing behaviour.realpath+ same-directory temp +os.replacepreserves mode, follows symlinks, never crosses a mount. There is no non-atomic write: if the temp file cannot be created or the rename fails, the target is untouched andwrite_failedretries through the transfer path (whose shell redirection needs only file write permission).python exit statussurvives the trailing cleanup via$?/exit $__ec, sopython3-missing detection works even on backends that do not relay stderr.Documentation and compatibility follow-up
python3as optional foredit_file: it enables the native fast path; without an executable interpreter the transfer fallback is used. The docs also describe the 112 KiB total-command limit and split-edit guidance.oldStringmust be non-empty,newStringcannot be null (""means deletion), and CRLF/lone-CR content is normalized to LF.performStringReplacement()bridge preserves the published v2.0.x exact-line-ending behavior; only the new typedstringReplacement()normalizes line endings. This keeps the bridge's behavior truthful, not just its JVM signature.Changes
BaseSandboxFilesystem.javaedit()as native inline script +editViaTransfer()fallback; removejsonEscape(); addLogger,$?exit passthrough, Jackson-based result parsing; document rare temp-file residue after a hard killFilesystemUtils.javaReplacementResultrecord +stringReplacement()(CRLF/lone-CR → LF); keep@Deprecated performStringReplacement(Object[]) as a compatibility bridge preserving the published exact-line-ending behavior; add shared argument validation and strict UTF-8 decodingLocalFilesystem.javaReplacementResult; reject nullnewString/ emptyoldString; drop its now-shared line-ending normalizationRemoteFilesystem.javaReplacementResult; reject nullnewString/ emptyoldStringBaseSandboxFilesystemTest.javash/python3integration tests; realdownloadFiles/uploadFilesin the local-shell helperTests
164 tests passing, 0 failures in the filesystem suites; 1363 tests passing, 0 failures, 10 skipped in the whole
agentscope-harnessmodule (includes upstream regression tests):BaseSandboxFilesystemTest: canned unit (native success/multiline params present only base64-encoded/exact error mapping/write_failedfallback/python3-missing via 126 and 127 and via stderr-only/oversized-argument rejection/invalid UTF-8 rejection/newString == null) +FilesystemUtilsTest(typed replacement, CRLF/LF/CR normalization and LF write-back, deprecated-bridge contract including exact CRLF behavior, argument validation, strict UTF-8 decoder) + real-shell CRLF parity between the native and fallback paths + Linux integration via realsh+python3(simple, special chars, replace-all, not-found, string-not-found, multiple-occurrences, symlink preserved (native), CRLF parity native-vs-fallback, heredoc quoting edges — delimiter-shaped path, single-quoted path (with a decoy sibling), backticked path, params ending in a newline — fallback symlink write-through vs replace, native/fallback replaceAll parity, malformed result JSON)FilesystemToolTest,LocalFilesystemModeTest,CompositeFilesystemTest,SandboxBackedFilesystemTestFixes #2397
Note on the branch name
The head branch is still
fix/sandbox-edit-python-to-java, which was chosen when the first revision removed the inline Python script in favour of a pure download-edit-upload flow. The current revision reverses that direction: it runs a static inline Python script inside the sandbox (with the download-edit-upload path kept only as a fallback), so the branch name no longer describes the change.Renaming a PR's head branch is not possible on GitHub — renaming the source branch does not redirect a cross-fork PR and instead closes it, losing the review history. The name is therefore left as-is; the PR title and this description reflect the actual approach.