Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 73 additions & 3 deletions src/agents/sandbox/apply_patch.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
import io
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, runtime_checkable
from uuid import uuid4

from ..apply_diff import ApplyDiffMode, apply_diff
from ..editor import ApplyPatchOperation, ApplyPatchOperationType, ApplyPatchResult
Expand Down Expand Up @@ -111,9 +113,11 @@ async def apply_operation(

moved_relative_path, moved_display_path = self._resolve_path(operation.move_to)
moved_destination = self._session.normalize_path(moved_relative_path)
await self._write_text(moved_destination, updated_text)
if moved_destination != destination:
await self._session.rm(destination, user=self._user)
await self._move_updated_text(
source=destination,
moved_destination=moved_destination,
text=updated_text,
)
return ApplyPatchResult(
output=f"Updated {display_path}\nMoved {display_path} to {moved_display_path}"
)
Expand Down Expand Up @@ -209,6 +213,72 @@ async def _read_text(self, destination: Path, *, op_path: str, decode_path: Path
path=op_path,
)

async def _move_updated_text(
self,
*,
source: Path,
moved_destination: Path,
text: str,
) -> None:
"""Apply an update that renames the file, without a window in which it does not exist.

Writing the destination and then removing the source destroys the file whenever the two
paths are one file on disk, which is what a case-only rename is on a filesystem that
folds case. Removing the source first destroys it whenever the replacement write fails.

So neither path is written or removed until the new content is committed somewhere else:
the text goes to a staging file, a single `mv` puts it at the destination, and only then
is the source removed when the filesystem says it is a different entry. When both names
are one entry and the leaf spellings differ, a second move changes the stored spelling.
Before the first move the original is untouched; after it the new content exists. There
is no moment where the only copy is in memory, and nothing is restored after the fact.

The identity answer can still go stale. On the different-entry branch, a writer that
replaces the source before the removal loses its file. On the same-entry branch, a writer
that replaces the source before the second move has its content moved onto the destination
and reported as the patched file. Closing either race needs an operation tied to the entry
whose identity was checked, which no backend here offers.

The staging file is a new inode, so a rename the filesystem folds onto the source path
replaces the original's mode and extended attributes. Carrying those across would mean
reading and reapplying them per backend; committing the content in a single `mv` is
worth more than the mode bits.

The staging name is a fixed length rather than a decoration of the destination name,
because a destination basename near the filesystem's 255-byte limit would make the
decorated name exceed it and the write would fail with ENAMETOOLONG.
"""
if source.as_posix() == moved_destination.as_posix():
# Not a rename, so nothing needs committing elsewhere. Writing in place is what an
# update without `move_to` does, and it keeps the inode, the mode and the xattrs.
#
# The comparison is on the spelling rather than on `Path` equality, which folds case
# on a Windows host. Whether two sandbox paths are one file is the sandbox's answer,
# not the host's: a Windows host talking to a case-sensitive sandbox would otherwise
# take this branch for a case-only rename and never create the new name. Paths that
# differ only in case go down the staging path, where `same_file` asks the sandbox.
await self._write_text(source, text)
return

staging = moved_destination.with_name(f".apply_patch-{uuid4().hex}.tmp")
try:
await self._write_text(staging, text)
await self._session.mv(staging, moved_destination, user=self._user)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve metadata for a move to the same path

When move_to is exactly the source path—a supported case already covered by test_editor_move_to_same_path_does_not_remove_the_file—moving the newly created staging file over it replaces the inode instead of updating it in place. On UnixLocalSandboxSession, this changes previously preserved metadata to the staging file's defaults, so a 0600 file can become 0644, an executable can lose its execute bit, and ACLs or extended attributes disappear. Bypass staging for the exact-same-path case or copy the existing metadata onto the staged replacement before committing it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve metadata when replacing an existing move target

When move_to names a distinct existing file, this mv replaces that file's inode with the newly created staging inode. On UnixLocalSandboxSession, the previous _write_text(moved_destination, ...) path truncated the existing inode, so its executable bits, ownership, ACLs, and extended attributes survived; after this change, an existing executable destination can silently become a default-mode non-executable file. The new same-path fast path does not cover this ordinary overwrite case, so preserve the target's metadata on the stage or retain the established in-place behavior when the target is a distinct entry.

AGENTS.md reference: AGENTS.md:L98-L98

Useful? React with 👍 / 👎.

except BaseException:
with contextlib.suppress(Exception):
await self._session.rm(staging, user=self._user)
raise
same_entry = await self._session.same_file(
source, moved_destination, follow_symlinks=False, user=self._user
)
if same_entry and source.name != moved_destination.name:
# On case-folding APFS, replacing an existing entry through a case-variant path
# updates its content but keeps its old spelling. Moving that same entry performs
# the requested case-only rename without touching the committed content.
await self._session.mv(source, moved_destination, user=self._user)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip the second move for parent-symlink aliases

When a remote workspace contains an in-root directory symlink (for example, alias -> real) and an update moves real/file to alias/file, the new leaf-only -L guard still makes same_file(..., follow_symlinks=False) return true because neither leaf is a symlink. This line then runs mv real/file alias/file; GNU mv reports that the arguments are the same file and exits nonzero, so apply_patch reports failure after already committing the updated content. This is fresh evidence beyond the earlier source-symlink comment because the symlink is in a parent component and therefore bypasses the added leaf check; same-file aliases that do not require a case-spelling rename must not take this second-move branch.

Useful? React with 👍 / 👎.

elif not same_entry:
await self._session.rm(source, user=self._user)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not remove a concurrently replaced source

If another process replaces or recreates source after the destination mv completes but before this rm runs, the intervening same_file result still authorizes deleting by pathname, so the SDK removes the other process's new file. This patch lengthens that pre-existing race with an additional remote identity-check round trip and contradicts the method's stated guarantee about concurrent writers; preserve the identity of the original source entry or use an ownership-aware atomic operation before removing it.

AGENTS.md reference: AGENTS.md:L104-L104

Useful? React with 👍 / 👎.


async def _write_text(self, destination: Path, text: str) -> None:
await self._session.mkdir(destination.parent, parents=True, user=self._user)
await self._session.write(
Expand Down
93 changes: 93 additions & 0 deletions src/agents/sandbox/session/base_sandbox_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,99 @@ async def rm(
if not result.ok():
raise ExecNonZeroError(result, command=cmd)

async def mv(
self,
source: Path | str,
destination: Path | str,
*,
user: str | User | None = None,
) -> None:
"""Rename a path, replacing the destination if it exists.

This is a rename, not `mv`'s other behavior. Given an existing directory as the
destination, `mv` puts the source inside it and reports success, which for a caller
that then removes the source is a way to delete a file while believing it moved. The
destination is checked in the same shell invocation as the move, which keeps the
check and the move in one round trip. It does not make them one syscall.

`mv -T` would say this directly and is GNU-only, so it is unavailable on the BSD
userland this also has to run against.

:param source: Path to move.
:param destination: Path to move it to.
:param user: Optional sandbox user to move as.
:raises ExecNonZeroError: If the destination is an existing directory, or the move
fails.
"""
source = await self._validate_path_access(source, for_write=True)
destination = await self._validate_path_access(destination, for_write=True)

source_arg = sandbox_path_str(source)
destination_arg = sandbox_path_str(destination)
cmd = (
"sh",
"-lc",
'if [ -d "$2" ]; then exit 3; fi\nmv -f -- "$1" "$2"',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the directory check atomic with the rename

If another process creates destination as a directory after [ -d "$2" ] returns false but before mv executes, mv succeeds by moving the staging file inside that directory—the checked mv --help explicitly lists the SOURCE... DIRECTORY form. _move_updated_text then removes the original source and reports success, while the updated content is left under its hidden staging name inside the directory. Use a rename/no-target-directory primitive that cannot reinterpret the destination after a check.

AGENTS.md reference: AGENTS.md:L104-L104

Useful? React with 👍 / 👎.

"sh",
source_arg,
destination_arg,
)
result = await self.exec(*cmd, shell=False, user=user)
if not result.ok():
raise ExecNonZeroError(
result, command=("sh", "-lc", "<mv>", source_arg, destination_arg)
)

async def same_file(
self,
left: Path | str,
right: Path | str,
*,
follow_symlinks: bool = True,
user: str | User | None = None,
) -> bool:
"""Return whether two paths name the same file on the sandbox filesystem.

This asks the filesystem, through `test -ef`, which compares device and inode. Two
paths that differ as strings can be one file: a filesystem that folds case stores
`notes.txt` and `Notes.txt` as a single entry, and APFS folds Unicode normalization
as well, so the NFC and NFD spellings of one accented name are also a single entry.
No string comparison can answer this, and neither can the host that is driving the
session, which may not be the kind of system the sandbox is running on.

`test -ef` resolves symlinks, so a symlink and the file it points at are the same
file by this test while being two directory entries. Pass ``follow_symlinks=False``
when the caller needs to distinguish those entries.

:param left: First path to compare.
:param right: Second path to compare.
:param follow_symlinks: If false, a symlink on either side is not the same file as
its target when the backend preserves the requested leaf path.
:param user: Optional sandbox user to compare as.
:returns: True when both paths resolve to the same file.
"""
left = await self._validate_path_access(left)
right = await self._validate_path_access(right)

left_arg = sandbox_path_str(left)
right_arg = sandbox_path_str(right)
test = '[ "$1" -ef "$2" ]'
if not follow_symlinks:
test = '[ ! -L "$1" ] && [ ! -L "$2" ] && ' + test
cmd = ("sh", "-lc", test, "sh", left_arg, right_arg)
result = await self.exec(*cmd, shell=False, user=user)
if result.exit_code == 0:
return True
# `[` answers "different file" with 1 and reports its own failures with 2, and a
# missing shell exits 127. Only 1 is an answer; anything else is the session
# failing to tell us, and a caller about to delete a file on the strength of this
# must not read that as "different".
if result.exit_code == 1:
return False
raise ExecNonZeroError(
result, command=("sh", "-lc", "<same_file_check>", left_arg, right_arg)
)

async def mkdir(
self,
path: Path | str,
Expand Down
19 changes: 19 additions & 0 deletions src/agents/sandbox/session/sandbox_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,25 @@ async def rm(
) -> None:
await self._inner.rm(path, recursive=recursive, user=user)

async def mv(
self,
source: Path | str,
destination: Path | str,
*,
user: str | User | None = None,
) -> None:
await self._inner.mv(source, destination, user=user)

async def same_file(
self,
left: Path | str,
right: Path | str,
*,
follow_symlinks: bool = True,
user: str | User | None = None,
) -> bool:
return await self._inner.same_file(left, right, follow_symlinks=follow_symlinks, user=user)

async def mkdir(
self,
path: Path | str,
Expand Down
28 changes: 28 additions & 0 deletions src/agents/testing/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
"exec",
"ls",
"mkdir",
"mv",
"pty_exec_start",
"pty_write_stdin",
"read",
"rm",
"same_file",
"write",
]
SandboxStepReason = Literal["invalid_input", "unknown_method", "invalid_matcher", "invalid_outcome"]
Expand Down Expand Up @@ -489,6 +491,32 @@ async def mkdir(
) -> None:
await self._invoke("mkdir", (path,), {"parents": parents, "user": user})

async def mv(
self,
source: Path | str,
destination: Path | str,
*,
user: str | User | None = None,
) -> None:
await self._invoke("mv", (source, destination), {"user": user})

async def same_file(
self,
left: Path | str,
right: Path | str,
*,
follow_symlinks: bool = True,
user: str | User | None = None,
) -> bool:
return cast(
bool,
await self._invoke(
"same_file",
(left, right),
{"follow_symlinks": follow_symlinks, "user": user},
),
)

async def apply_patch(
self,
operations: ApplyPatchOperation
Expand Down
Loading