axio-tools-docker 0.9.7__tar.gz → 0.9.9__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.9
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.9"
4
4
  description = "Docker sandbox tools for Axio"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -72,18 +72,18 @@ async def shell(command: str, timeout: float = 5, cwd: str = ".", stdin: str | N
72
72
  return await sandbox.exec(cmd, timeout=timeout, stdin=stdin)
73
73
 
74
74
 
75
- async def write_file(file_path: str, content: str, mode: int = 0o644) -> str:
75
+ async def write_file(path: str, content: str, mode: int = 0o644) -> str:
76
76
  """Create or overwrite a file with the given content. Parent directories
77
77
  are created automatically. Use this for new files or full rewrites.
78
78
  For partial edits prefer patch_file instead."""
79
79
  sandbox: DockerSandbox = CONTEXT.get()
80
- path = _resolve_path(sandbox.workdir, file_path)
81
- return await sandbox.write_file(path, content, mode=mode)
80
+ resolved = _resolve_path(sandbox.workdir, path)
81
+ return await sandbox.write_file(resolved, content, mode=mode)
82
82
 
83
83
 
84
84
  async def read_file(
85
- filename: str,
86
- max_chars: int = 32768,
85
+ path: str,
86
+ max_chars: int = 524288, # ~128k tokens at ~4 chars/token
87
87
  binary_as_hex: bool = True,
88
88
  start_line: int | None = None,
89
89
  end_line: int | None = None,
@@ -96,8 +96,9 @@ async def read_file(
96
96
  are truncated to max_chars. Always read the file before editing it with
97
97
  write_file or patch_file."""
98
98
  sandbox: DockerSandbox = CONTEXT.get()
99
- path = _resolve_path(sandbox.workdir, filename)
100
- raw = await sandbox.read_file_bytes(path)
99
+ resolved = _resolve_path(sandbox.workdir, path)
100
+ sandbox._patched_files.discard(resolved)
101
+ raw = await sandbox.read_file_bytes(resolved)
101
102
  try:
102
103
  text = raw.decode()
103
104
  except UnicodeDecodeError:
@@ -117,14 +118,14 @@ async def read_file(
117
118
  return result
118
119
 
119
120
 
120
- async def list_files(directory: str = ".") -> str:
121
+ async def list_files(path: str = ".") -> str:
121
122
  """List files and directories. Shows permissions, size, modification time,
122
123
  and name for each entry. Directories are listed first and marked with
123
124
  a trailing slash. Use this to explore the project structure before
124
125
  reading or editing files."""
125
126
  sandbox: DockerSandbox = CONTEXT.get()
126
- path = _resolve_path(sandbox.workdir, directory)
127
- tar = await sandbox.get_archive(path)
127
+ resolved = _resolve_path(sandbox.workdir, path)
128
+ tar = await sandbox.get_archive(resolved)
128
129
 
129
130
  members = tar.getmembers()
130
131
  if not members:
@@ -174,23 +175,37 @@ async def run_python(code: str, cwd: str = ".", timeout: float = 5, stdin: str |
174
175
  return await sandbox.exec(cmd, timeout=timeout, stdin=stdin)
175
176
 
176
177
 
177
- async def patch_file(file_path: str, from_line: int, to_line: int, content: str, mode: int = 0o644) -> str:
178
+ async def patch_file(path: str, from_line: int, to_line: int, content: str, mode: int = 0o644) -> str:
178
179
  """Replace a range of lines in an existing file. Lines are 1-indexed:
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
- path = _resolve_path(sandbox.workdir, file_path)
186
- raw = await sandbox.read_file_bytes(path)
188
+ resolved = _resolve_path(sandbox.workdir, path)
189
+ if resolved in sandbox._patched_files:
190
+ raise RuntimeError(
191
+ f"{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
+ )
194
+ raw = await sandbox.read_file_bytes(resolved)
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(resolved, result, mode=mode)
207
+ sandbox._patched_files.add(resolved)
208
+ return f"{len(result)} bytes written to {resolved}"
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 {}
@@ -526,7 +542,12 @@ class DockerSandbox:
526
542
  async def get_archive(self, path: str) -> tarfile.TarFile:
527
543
  """Fetch a path from the container as a TarFile object."""
528
544
  assert self._container is not None
529
- return cast(tarfile.TarFile, await self._container.get_archive(path=path))
545
+ try:
546
+ return cast(tarfile.TarFile, await self._container.get_archive(path=path))
547
+ except aiodocker.exceptions.DockerError as exc:
548
+ if exc.status == 404:
549
+ raise FileNotFoundError(f"No such file or directory: {path!r}") from exc
550
+ raise
530
551
 
531
552
  async def read_file_bytes(self, path: str) -> bytes:
532
553
  """Read a file from inside the container and return raw bytes."""
@@ -94,14 +94,14 @@ async def test_write_file_mode(sandbox: DockerSandbox) -> None:
94
94
  async def test_read_file_line_range(sandbox: DockerSandbox) -> None:
95
95
  await sandbox.write_file("/workspace/lines.txt", "a\nb\nc\nd\ne\n")
96
96
  tool = next(t for t in sandbox.tools if t.name == "read_file")
97
- result = await tool(filename="lines.txt", start_line=2, end_line=4)
97
+ result = await tool(path="lines.txt", start_line=2, end_line=4)
98
98
  assert result.strip() == "b\nc\nd"
99
99
 
100
100
 
101
101
  async def test_read_file_line_numbers(sandbox: DockerSandbox) -> None:
102
102
  await sandbox.write_file("/workspace/numbered.txt", "foo\nbar\n")
103
103
  tool = next(t for t in sandbox.tools if t.name == "read_file")
104
- result = await tool(filename="numbered.txt", line_numbers=True)
104
+ result = await tool(path="numbered.txt", line_numbers=True)
105
105
  assert "1\tfoo" in result
106
106
  assert "2\tbar" in result
107
107
 
@@ -109,10 +109,17 @@ async def test_read_file_line_numbers(sandbox: DockerSandbox) -> None:
109
109
  async def test_read_file_truncation(sandbox: DockerSandbox) -> None:
110
110
  await sandbox.write_file("/workspace/big.txt", "x" * 100)
111
111
  tool = next(t for t in sandbox.tools if t.name == "read_file")
112
- result = await tool(filename="big.txt", max_chars=10)
112
+ result = await tool(path="big.txt", max_chars=10)
113
113
  assert "truncated" in result
114
114
 
115
115
 
116
+ async def test_read_file_missing_raises_file_not_found(sandbox: DockerSandbox) -> None:
117
+ # A 404 from the container archive endpoint must surface as a clear
118
+ # FileNotFoundError, not a raw Docker 404.
119
+ with pytest.raises(FileNotFoundError, match="No such file or directory"):
120
+ await sandbox.read_file_bytes("/workspace/nope.txt")
121
+
122
+
116
123
  async def test_list_files(sandbox: DockerSandbox) -> None:
117
124
  await sandbox.exec("mkdir -p /workspace/listing")
118
125
  await sandbox.write_file("/workspace/listing/a.py", "x")
@@ -120,7 +127,7 @@ async def test_list_files(sandbox: DockerSandbox) -> None:
120
127
  await sandbox.exec("mkdir -p /workspace/listing/subdir")
121
128
 
122
129
  tool = next(t for t in sandbox.tools if t.name == "list_files")
123
- result = await tool(directory="/workspace/listing")
130
+ result = await tool(path="/workspace/listing")
124
131
 
125
132
  assert "a.py" in result
126
133
  assert "b.txt" in result
@@ -131,7 +138,7 @@ async def test_list_files(sandbox: DockerSandbox) -> None:
131
138
  async def test_patch_file(sandbox: DockerSandbox) -> None:
132
139
  await sandbox.write_file("/workspace/patch_me.txt", "line1\nline2\nline3\n")
133
140
  tool = next(t for t in sandbox.tools if t.name == "patch_file")
134
- await tool(file_path="/workspace/patch_me.txt", from_line=2, to_line=2, content="REPLACED")
141
+ await tool(path="/workspace/patch_me.txt", from_line=2, to_line=2, content="REPLACED")
135
142
  raw = await sandbox.read_file_bytes("/workspace/patch_me.txt")
136
143
  assert raw == b"line1\nREPLACED\nline3\n"
137
144
 
@@ -140,11 +147,110 @@ async def test_patch_file_insert(sandbox: DockerSandbox) -> None:
140
147
  """to_line = from_line - 1 inserts without deleting."""
141
148
  await sandbox.write_file("/workspace/insert_me.txt", "line1\nline3\n")
142
149
  tool = next(t for t in sandbox.tools if t.name == "patch_file")
143
- await tool(file_path="/workspace/insert_me.txt", from_line=2, to_line=1, content="line2")
150
+ await tool(path="/workspace/insert_me.txt", from_line=2, to_line=1, content="line2")
144
151
  raw = await sandbox.read_file_bytes("/workspace/insert_me.txt")
145
152
  assert raw == b"line1\nline2\nline3\n"
146
153
 
147
154
 
155
+ # ---------------------------------------------------------------------------
156
+ # patch_file validation
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ async def test_patch_file_from_line_zero_rejected(sandbox: DockerSandbox) -> None:
161
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
162
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
163
+ with pytest.raises(Exception, match="from_line=0"):
164
+ await tool(path="/workspace/v.txt", from_line=0, to_line=1, content="x")
165
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
166
+
167
+
168
+ async def test_patch_file_negative_from_line_rejected(sandbox: DockerSandbox) -> None:
169
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
170
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
171
+ with pytest.raises(Exception, match="from_line=-1"):
172
+ await tool(path="/workspace/v.txt", from_line=-1, to_line=1, content="x")
173
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
174
+
175
+
176
+ async def test_patch_file_from_line_beyond_end_rejected(sandbox: DockerSandbox) -> None:
177
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
178
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
179
+ with pytest.raises(Exception, match="from_line=5"):
180
+ await tool(path="/workspace/v.txt", from_line=5, to_line=5, content="x")
181
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
182
+
183
+
184
+ async def test_patch_file_to_line_beyond_end_rejected(sandbox: DockerSandbox) -> None:
185
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\n")
186
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
187
+ with pytest.raises(Exception, match="to_line=4"):
188
+ await tool(path="/workspace/v.txt", from_line=2, to_line=4, content="x")
189
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\n"
190
+
191
+
192
+ async def test_patch_file_gap_range_rejected(sandbox: DockerSandbox) -> None:
193
+ """from_line > to_line + 1 must be rejected."""
194
+ await sandbox.write_file("/workspace/v.txt", "a\nb\nc\nd\ne\n")
195
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
196
+ with pytest.raises(Exception, match="to_line=2"):
197
+ await tool(path="/workspace/v.txt", from_line=4, to_line=2, content="x")
198
+ assert await sandbox.read_file_bytes("/workspace/v.txt") == b"a\nb\nc\nd\ne\n"
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # patch_file double-patching guard
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ async def test_patch_file_second_patch_rejected(sandbox: DockerSandbox) -> None:
207
+ """Second patch to the same file without re-reading must raise RuntimeError."""
208
+ await sandbox.write_file("/workspace/dp.txt", "a\nb\nc\n")
209
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
210
+ await tool(path="/workspace/dp.txt", from_line=1, to_line=1, content="A\n")
211
+ with pytest.raises(Exception, match="already patched"):
212
+ await tool(path="/workspace/dp.txt", from_line=2, to_line=2, content="B\n")
213
+ raw = await sandbox.read_file_bytes("/workspace/dp.txt")
214
+ assert raw == b"A\nb\nc\n"
215
+
216
+
217
+ async def test_patch_file_read_clears_double_patch_guard(sandbox: DockerSandbox) -> None:
218
+ """Re-reading the file via the read_file tool clears the patch guard."""
219
+ await sandbox.write_file("/workspace/rg.txt", "a\nb\nc\n")
220
+ patch_tool = next(t for t in sandbox.tools if t.name == "patch_file")
221
+ read_tool = next(t for t in sandbox.tools if t.name == "read_file")
222
+ await patch_tool(path="/workspace/rg.txt", from_line=1, to_line=1, content="A\n")
223
+ await read_tool(path="/workspace/rg.txt", line_numbers=True)
224
+ await patch_tool(path="/workspace/rg.txt", from_line=2, to_line=2, content="B\n")
225
+ raw = await sandbox.read_file_bytes("/workspace/rg.txt")
226
+ assert raw == b"A\nB\nc\n"
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # patch_file special file content
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ async def test_patch_file_null_bytes_in_preserved_lines_survive(sandbox: DockerSandbox) -> None:
235
+ """Null bytes in lines not being replaced must not be stripped."""
236
+ await sandbox.write_file("/workspace/null.txt", "line1\nline2\x00null\nline3\n")
237
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
238
+ await tool(path="/workspace/null.txt", from_line=1, to_line=1, content="replaced\n")
239
+ raw = await sandbox.read_file_bytes("/workspace/null.txt")
240
+ assert b"line2\x00null" in raw, "null byte in preserved line was stripped"
241
+ assert raw.startswith(b"replaced\n")
242
+
243
+
244
+ async def test_patch_file_binary_file_raises_before_write(sandbox: DockerSandbox) -> None:
245
+ """Patching a non-UTF-8 binary file must fail before any modification."""
246
+ await sandbox.exec("python3 -c \"open('/workspace/bin.bin','wb').write(bytes(range(128,200)))\"")
247
+ tool = next(t for t in sandbox.tools if t.name == "patch_file")
248
+ original = await sandbox.read_file_bytes("/workspace/bin.bin")
249
+ with pytest.raises(Exception, match="codec can't decode"):
250
+ await tool(path="/workspace/bin.bin", from_line=1, to_line=1, content="hello\n")
251
+ assert await sandbox.read_file_bytes("/workspace/bin.bin") == original
252
+
253
+
148
254
  async def test_run_python(sandbox: DockerSandbox) -> None:
149
255
  tool = next(t for t in sandbox.tools if t.name == "run_python")
150
256
  result = await tool(code="print(2 + 2)")