axio-tools-docker 0.9.7__tar.gz → 0.9.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axio-tools-docker
3
- Version: 0.9.7
3
+ Version: 0.9.8
4
4
  Summary: Docker sandbox tools for Axio
5
5
  Project-URL: Documentation, https://docs.axio-agent.com
6
6
  Project-URL: Homepage, https://github.com/mosquito/axio-agent
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "axio-tools-docker"
3
- version = "0.9.7"
3
+ version = "0.9.8"
4
4
  description = "Docker sandbox tools for Axio"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -97,6 +97,7 @@ async def read_file(
97
97
  write_file or patch_file."""
98
98
  sandbox: DockerSandbox = CONTEXT.get()
99
99
  path = _resolve_path(sandbox.workdir, filename)
100
+ sandbox._patched_files.discard(path)
100
101
  raw = await sandbox.read_file_bytes(path)
101
102
  try:
102
103
  text = raw.decode()
@@ -179,18 +180,32 @@ async def patch_file(file_path: str, from_line: int, to_line: int, content: str,
179
180
  from_line and to_line are both inclusive (from_line=2, to_line=4 replaces
180
181
  lines 2, 3, 4). To insert without deleting, set to_line = from_line - 1.
181
182
  Always read the file first with line_numbers=True to get correct line numbers.
183
+ Patch each file at most once per read — re-read with line_numbers=True after
184
+ patching before issuing another patch to the same file.
182
185
  Use this for surgical edits instead of rewriting the whole file with
183
186
  write_file."""
184
187
  sandbox: DockerSandbox = CONTEXT.get()
185
188
  path = _resolve_path(sandbox.workdir, file_path)
189
+ if path in sandbox._patched_files:
190
+ raise RuntimeError(
191
+ f"{file_path!r} was already patched since the last read. "
192
+ "Re-read the file with line_numbers=True to get updated line numbers before patching again."
193
+ )
186
194
  raw = await sandbox.read_file_bytes(path)
187
195
  lines = raw.decode().splitlines(keepends=True)
196
+ n = len(lines)
197
+ if not (1 <= from_line <= n + 1):
198
+ raise ValueError(f"from_line={from_line} out of range; file has {n} lines (valid: 1..{n + 1})")
199
+ if not (from_line - 1 <= to_line <= n):
200
+ raise ValueError(f"to_line={to_line} out of range (valid: {from_line - 1}..{n})")
188
201
  content_lines = content.splitlines(keepends=True)
189
202
  if content_lines and not content_lines[-1].endswith("\n"):
190
203
  content_lines[-1] += "\n"
191
204
  new_lines = lines[: from_line - 1] + content_lines + lines[to_line:]
192
- await sandbox.write_file(path, "".join(new_lines), mode=mode)
193
- return f"{len(new_lines)} lines written to {path}"
205
+ result = "".join(new_lines)
206
+ await sandbox.write_file(path, result, mode=mode)
207
+ sandbox._patched_files.add(path)
208
+ return f"{len(result)} bytes written to {path}"
194
209
 
195
210
 
196
211
  # ---------------------------------------------------------------------------
@@ -302,6 +317,7 @@ class DockerSandbox:
302
317
  self.privileged = privileged
303
318
  self.ulimits: dict[str, int | tuple[int, int]] = ulimits or {}
304
319
  self.tmpfs: dict[str, str] = tmpfs or {}
320
+ self._patched_files: set[str] = set()
305
321
  self.ports: dict[int, int] = ports or {}
306
322
  self.platform = platform
307
323
  self.extra_hosts: dict[str, str] = extra_hosts or {}
@@ -145,6 +145,105 @@ async def test_patch_file_insert(sandbox: DockerSandbox) -> None:
145
145
  assert raw == b"line1\nline2\nline3\n"
146
146
 
147
147
 
148
+ # ---------------------------------------------------------------------------
149
+ # patch_file validation
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ async def test_patch_file_from_line_zero_rejected(sandbox: DockerSandbox) -> None:
154
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
155
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
156
+ with pytest.raises(Exception, match="from_line=0"):
157
+ await tool(file_path="/workspace/v.txt", from_line=0, to_line=1, content="x")
158
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
159
+
160
+
161
+ async def test_patch_file_negative_from_line_rejected(sandbox: DockerSandbox) -> None:
162
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
163
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
164
+ with pytest.raises(Exception, match="from_line=-1"):
165
+ await tool(file_path="/workspace/v.txt", from_line=-1, to_line=1, content="x")
166
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
167
+
168
+
169
+ async def test_patch_file_from_line_beyond_end_rejected(sandbox: DockerSandbox) -> None:
170
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
171
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
172
+ with pytest.raises(Exception, match="from_line=5"):
173
+ await tool(file_path="/workspace/v.txt", from_line=5, to_line=5, content="x")
174
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
175
+
176
+
177
+ async def test_patch_file_to_line_beyond_end_rejected(sandbox: DockerSandbox) -> None:
178
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
179
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
180
+ with pytest.raises(Exception, match="to_line=4"):
181
+ await tool(file_path="/workspace/v.txt", from_line=2, to_line=4, content="x")
182
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
183
+
184
+
185
+ async def test_patch_file_gap_range_rejected(sandbox: DockerSandbox) -> None:
186
+ """from_line > to_line + 1 must be rejected."""
187
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\nd\ne\n")
188
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
189
+ with pytest.raises(Exception, match="to_line=2"):
190
+ await tool(file_path="/workspace/v.txt", from_line=4, to_line=2, content="x")
191
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\nd\ne\n"
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # patch_file double-patching guard
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ async def test_patch_file_second_patch_rejected(sandbox: DockerSandbox) -> None:
200
+ """Second patch to the same file without re-reading must raise RuntimeError."""
201
+ await sandbox.write_file("/workspace/dp.txt", "a\nb\nc\n")
202
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
203
+ await tool(file_path="/workspace/dp.txt", from_line=1, to_line=1, content="A\n")
204
+ with pytest.raises(Exception, match="already patched"):
205
+ await tool(file_path="/workspace/dp.txt", from_line=2, to_line=2, content="B\n")
206
+ raw = await sandbox.read_file_bytes("/workspace/dp.txt")
207
+ assert raw == b"A\nb\nc\n"
208
+
209
+
210
+ async def test_patch_file_read_clears_double_patch_guard(sandbox: DockerSandbox) -> None:
211
+ """Re-reading the file via the read_file tool clears the patch guard."""
212
+ await sandbox.write_file("/workspace/rg.txt", "a\nb\nc\n")
213
+ patch_tool = next(t for t in sandbox.tools if t.name == "patch_file")
214
+ read_tool = next(t for t in sandbox.tools if t.name == "read_file")
215
+ await patch_tool(file_path="/workspace/rg.txt", from_line=1, to_line=1, content="A\n")
216
+ await read_tool(filename="/workspace/rg.txt", line_numbers=True)
217
+ await patch_tool(file_path="/workspace/rg.txt", from_line=2, to_line=2, content="B\n")
218
+ raw = await sandbox.read_file_bytes("/workspace/rg.txt")
219
+ assert raw == b"A\nB\nc\n"
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # patch_file special file content
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ async def test_patch_file_null_bytes_in_preserved_lines_survive(sandbox: DockerSandbox) -> None:
228
+ """Null bytes in lines not being replaced must not be stripped."""
229
+ await sandbox.write_file("/workspace/null.txt", "line1\nline2\x00null\nline3\n")
230
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
231
+ await tool(file_path="/workspace/null.txt", from_line=1, to_line=1, content="replaced\n")
232
+ raw = await sandbox.read_file_bytes("/workspace/null.txt")
233
+ assert b"line2\x00null" in raw, "null byte in preserved line was stripped"
234
+ assert raw.startswith(b"replaced\n")
235
+
236
+
237
+ async def test_patch_file_binary_file_raises_before_write(sandbox: DockerSandbox) -> None:
238
+ """Patching a non-UTF-8 binary file must fail before any modification."""
239
+ await sandbox.exec("python3 -c \"open('/workspace/bin.bin','wb').write(bytes(range(128,200)))\"")
240
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
241
+ original = await sandbox.read_file_bytes("/workspace/bin.bin")
242
+ with pytest.raises(Exception, match="codec can't decode"):
243
+ await tool(file_path="/workspace/bin.bin", from_line=1, to_line=1, content="hello\n")
244
+ assert await sandbox.read_file_bytes("/workspace/bin.bin") == original
245
+
246
+
148
247
  async def test_run_python(sandbox: DockerSandbox) -> None:
149
248
  tool = next(t for t in sandbox.tools if t.name == "run_python")
150
249
  result = await tool(code="print(2 + 2)")