minimalcode 1.5.2__py3-none-any.whl

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.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,435 @@
1
+ """Bounded child-process execution and resource limiting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import ctypes
7
+ import locale
8
+ import math
9
+ import os
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import threading
15
+ from collections.abc import Callable, Mapping
16
+ from pathlib import Path
17
+ from typing import IO, Any
18
+
19
+ from .policy import SandboxPolicy
20
+
21
+
22
+ def _fallback_encodings() -> tuple[str, ...]:
23
+ """Return the code pages a child may have written instead of UTF-8.
24
+
25
+ Children are started with a UTF-8 environment, but a Windows console tool
26
+ that ignores it (cmd builtins, `type`, localized CLIs) still writes the
27
+ active ANSI or OEM code page.
28
+ """
29
+ encodings: list[str] = []
30
+ if os.name == "nt":
31
+ # `type` and cmd builtins write the console output code page.
32
+ for getter in ("GetConsoleOutputCP", "GetOEMCP"):
33
+ try:
34
+ code_page = int(getattr(ctypes.windll.kernel32, getter)())
35
+ except (AttributeError, OSError, ValueError):
36
+ continue
37
+ if code_page:
38
+ encodings.append(f"cp{code_page}")
39
+ preferred = locale.getpreferredencoding(False)
40
+ if preferred:
41
+ encodings.append(preferred)
42
+ return tuple(dict.fromkeys(encodings))
43
+
44
+
45
+ def truncate_text(text: str, limit: int) -> tuple[str, bool]:
46
+ """Keep a bounded head and tail, with a marker naming the dropped amount."""
47
+ if limit <= 0:
48
+ return ("", bool(text)) if text else ("", False)
49
+ if len(text) <= limit:
50
+ return text, False
51
+ marker = f"\n... <truncated {len(text) - limit} chars> ...\n"
52
+ if limit <= len(marker):
53
+ return text[:limit], True
54
+ available = limit - len(marker)
55
+ head = int(available * 0.4)
56
+ tail = available - head
57
+ return text[:head] + marker + text[-tail:], True
58
+
59
+
60
+ class _Capture:
61
+ """Keep a bounded head and tail of a stream instead of all of it."""
62
+
63
+ def __init__(self, max_chars: int) -> None:
64
+ # Four bytes is the widest UTF-8 encoding of one character.
65
+ self.limit_bytes = max(64, max_chars * 4 + 16)
66
+ self.head = bytearray()
67
+ self.tail = bytearray()
68
+ self.dropped = False
69
+ # A timeout path can snapshot the capture while the reader thread is
70
+ # still draining the pipe, so mutations and reads are serialized.
71
+ self._lock = threading.Lock()
72
+
73
+ def consume(self, chunk: bytes) -> None:
74
+ """Append a chunk, evicting old tail bytes once the budget is full."""
75
+ head_limit = max(1, self.limit_bytes * 3 // 5)
76
+ with self._lock:
77
+ if len(self.head) < head_limit:
78
+ take = min(head_limit - len(self.head), len(chunk))
79
+ self.head.extend(chunk[:take])
80
+ chunk = chunk[take:]
81
+ if chunk:
82
+ self.tail.extend(chunk)
83
+ tail_limit = max(0, self.limit_bytes - len(self.head))
84
+ if len(self.tail) > tail_limit:
85
+ del self.tail[: len(self.tail) - tail_limit]
86
+ self.dropped = True
87
+
88
+ def text(self) -> str:
89
+ """Decode the retained bytes as UTF-8 with replacement."""
90
+ with self._lock:
91
+ return _decode_output(bytes(self.head) + bytes(self.tail))
92
+
93
+
94
+ def _pump(stream: IO[bytes], capture: _Capture) -> None:
95
+ """Read a pipe to EOF in a daemon thread and always close it."""
96
+ try:
97
+ read = getattr(stream, "read1", stream.read)
98
+ while True:
99
+ chunk = read(8192)
100
+ if not chunk:
101
+ break
102
+ capture.consume(chunk)
103
+ except (OSError, ValueError):
104
+ pass
105
+ finally:
106
+ try:
107
+ stream.close()
108
+ except OSError:
109
+ pass
110
+
111
+
112
+ def _join_readers(threads: list[threading.Thread], timeout: float) -> None:
113
+ """Wait briefly for pipe readers without blocking process cleanup."""
114
+ for thread in threads:
115
+ thread.join(timeout=timeout)
116
+
117
+
118
+ def _finish_capture(capture: _Capture, limit: int) -> tuple[str, bool]:
119
+ """Apply the character budget and mark dropped middle bytes as truncated."""
120
+ text, truncated = truncate_text(capture.text(), limit)
121
+ if not capture.dropped:
122
+ return text, truncated
123
+ if not truncated:
124
+ marker = "\n... <truncated> ...\n"
125
+ if limit <= len(marker):
126
+ text = text[:limit]
127
+ else:
128
+ text = text[: limit - len(marker)] + marker
129
+ return text, True
130
+
131
+
132
+ def _run_cleanup(callback: Callable[[], None] | None) -> None:
133
+ """Run a backend cleanup hook without letting it mask the real failure."""
134
+ if callback is None:
135
+ return
136
+ with contextlib.suppress(Exception):
137
+ callback()
138
+
139
+
140
+ def _descendant_pids(root_pid: int) -> list[int]:
141
+ """Return descendants of ``root_pid`` using ps when it is available."""
142
+ try:
143
+ completed = subprocess.run(
144
+ ["ps", "-eo", "pid=,ppid="],
145
+ stdin=subprocess.DEVNULL,
146
+ capture_output=True,
147
+ text=True,
148
+ timeout=5,
149
+ check=False,
150
+ )
151
+ except (OSError, subprocess.TimeoutExpired):
152
+ return []
153
+ children: dict[int, list[int]] = {}
154
+ for line in completed.stdout.splitlines():
155
+ parts = line.split()
156
+ if len(parts) != 2:
157
+ continue
158
+ try:
159
+ pid, parent = int(parts[0]), int(parts[1])
160
+ except ValueError:
161
+ continue
162
+ children.setdefault(parent, []).append(pid)
163
+ result: list[int] = []
164
+ pending = list(children.get(root_pid, ()))
165
+ while pending:
166
+ pid = pending.pop()
167
+ result.append(pid)
168
+ pending.extend(children.get(pid, ()))
169
+ return result
170
+
171
+
172
+ def _terminate_tree(process: subprocess.Popen[bytes]) -> None:
173
+ """Kill a timed-out command and every process it spawned."""
174
+ if os.name == "nt":
175
+ try:
176
+ subprocess.run(
177
+ ["taskkill", "/F", "/T", "/PID", str(process.pid)],
178
+ stdin=subprocess.DEVNULL,
179
+ stdout=subprocess.DEVNULL,
180
+ stderr=subprocess.DEVNULL,
181
+ timeout=10,
182
+ check=False,
183
+ )
184
+ except (OSError, subprocess.TimeoutExpired):
185
+ pass
186
+ else:
187
+ # A child that called setsid() is no longer in our process group, so
188
+ # collect descendants before the group kill and signal them directly.
189
+ descendants = _descendant_pids(process.pid)
190
+ try:
191
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
192
+ except OSError:
193
+ pass
194
+ for pid in descendants:
195
+ try:
196
+ os.kill(pid, signal.SIGKILL)
197
+ except OSError:
198
+ continue
199
+ # The tree kill can race with an already-exiting process; the direct kill
200
+ # is a harmless fallback and ensures the leader is reaped.
201
+ try:
202
+ process.kill()
203
+ except OSError:
204
+ pass
205
+ try:
206
+ process.wait(timeout=10)
207
+ except subprocess.TimeoutExpired:
208
+ pass
209
+
210
+
211
+ def _decode_output(data: bytes | str | None) -> str:
212
+ """Decode captured output defensively.
213
+
214
+ UTF-8 is tried strictly first, so well-behaved output is untouched. Only
215
+ bytes that are not UTF-8 fall back to the local code page: a Windows
216
+ console tool that ignores `PYTHONIOENCODING` writes cp936/cp1252, and
217
+ decoding that as UTF-8 turned every non-ASCII character into U+FFFD.
218
+ """
219
+ if data is None:
220
+ return ""
221
+ if isinstance(data, str):
222
+ return data
223
+ try:
224
+ return data.decode("utf-8")
225
+ except UnicodeDecodeError:
226
+ pass
227
+ for encoding in _fallback_encodings():
228
+ try:
229
+ return data.decode(encoding)
230
+ except (UnicodeDecodeError, LookupError):
231
+ continue
232
+ return data.decode("utf-8", errors="replace")
233
+
234
+
235
+ def _child_env(
236
+ user_env: Mapping[str, str] | None,
237
+ temp_path: str | None,
238
+ ) -> dict[str, str]:
239
+ """Build a deterministic UTF-8 child environment.
240
+
241
+ User-provided values are applied last so tools can override locale or
242
+ explicit variables without inheriting a different default stack.
243
+ """
244
+ env = os.environ.copy()
245
+ env.update(
246
+ {
247
+ "NO_COLOR": "1",
248
+ "CLICOLOR": "0",
249
+ "PYTHONIOENCODING": "utf-8",
250
+ "LANG": env.get("LANG", "C.UTF-8"),
251
+ }
252
+ )
253
+ if temp_path:
254
+ env["TMPDIR"] = temp_path
255
+ env["TMP"] = temp_path
256
+ env["TEMP"] = temp_path
257
+ env["MINIMALCODE_SANDBOX_TMPDIR"] = temp_path
258
+ if user_env:
259
+ env.update({str(key): str(value) for key, value in user_env.items()})
260
+ return env
261
+
262
+
263
+ def _set_limit(resource: Any, kind: int, value: int) -> None:
264
+ """Set one rlimit without exceeding the inherited hard limit."""
265
+ try:
266
+ _, hard = resource.getrlimit(kind)
267
+ limit = value if hard == resource.RLIM_INFINITY else min(value, hard)
268
+ resource.setrlimit(kind, (limit, hard))
269
+ except (OSError, ValueError):
270
+ # Resource limits are best-effort; execution should still proceed.
271
+ pass
272
+
273
+
274
+ def _posix_limits(
275
+ policy: SandboxPolicy, timeout: float, *, linux: bool
276
+ ) -> list[tuple[str, int]]:
277
+ """Return the ``(rlimit name, value)`` pairs the baseline should apply."""
278
+ limits: list[tuple[str, int]] = [
279
+ ("RLIMIT_CORE", 0),
280
+ ("RLIMIT_CPU", max(1, math.ceil(timeout) + 1)),
281
+ ]
282
+ if linux:
283
+ if policy.max_memory_mb:
284
+ limits.append(("RLIMIT_AS", policy.max_memory_mb * 1024 * 1024))
285
+ if policy.max_processes:
286
+ limits.append(("RLIMIT_NPROC", policy.max_processes))
287
+ return limits
288
+
289
+
290
+ def _resource_limiter(
291
+ policy: SandboxPolicy, timeout: float
292
+ ) -> Callable[[], None] | None:
293
+ """Return a POSIX pre-exec hook that caps CPU and opt-in memory/processes.
294
+
295
+ Address-space (`RLIMIT_AS`) limits are opt-in because they are per-process
296
+ reservations, not resident memory: .NET (pwsh), JVM, and Go children reserve
297
+ several gigabytes of virtual address space and abort with "Out of memory"
298
+ long before they touch that much RAM.
299
+ """
300
+ if os.name == "nt":
301
+ return None
302
+ try:
303
+ import resource
304
+ except ImportError: # pragma: no cover - POSIX always ships resource
305
+ return None
306
+
307
+ limits = _posix_limits(policy, timeout, linux=sys.platform.startswith("linux"))
308
+
309
+ def apply() -> None:
310
+ for name, value in limits:
311
+ kind = getattr(resource, name, None)
312
+ if kind is not None:
313
+ _set_limit(resource, kind, value)
314
+
315
+ return apply
316
+
317
+
318
+ def _execute(
319
+ argv: list[str],
320
+ *,
321
+ cwd: Path,
322
+ timeout: float,
323
+ max_output_chars: int,
324
+ env: Mapping[str, str],
325
+ stdin_text: str | None,
326
+ shell_label: str,
327
+ preexec: Callable[[], None] | None = None,
328
+ on_timeout: Callable[[], None] | None = None,
329
+ ) -> dict[str, Any]:
330
+ """Run one prepared command with bounded captures and tree-wide timeout."""
331
+ tree_options: dict[str, object] = (
332
+ {} if os.name == "nt" else {"start_new_session": True}
333
+ )
334
+ if (
335
+ preexec is not None
336
+ and threading.current_thread() is not threading.main_thread()
337
+ ):
338
+ # Python documents preexec_fn as unsafe in a threaded process: the
339
+ # child can deadlock before exec if another thread holds a lock. The
340
+ # concurrent-subagent path launches its children from worker threads;
341
+ # those children re-apply limits to their own commands in their own
342
+ # main thread, so skipping the hook here is safer than risking a hang.
343
+ preexec = None
344
+ try:
345
+ with tempfile.TemporaryFile() as in_file:
346
+ if stdin_text is None:
347
+ stdin_source: object = subprocess.DEVNULL
348
+ else:
349
+ in_file.write(str(stdin_text).encode("utf-8"))
350
+ in_file.seek(0)
351
+ stdin_source = in_file
352
+ try:
353
+ process = subprocess.Popen(
354
+ argv,
355
+ cwd=cwd,
356
+ env=dict(env),
357
+ stdin=stdin_source,
358
+ stdout=subprocess.PIPE,
359
+ stderr=subprocess.PIPE,
360
+ # Limits must be inherited by exec; there is no portable
361
+ # alternative in the standard library.
362
+ preexec_fn=preexec, # noqa: PLW1509
363
+ **tree_options,
364
+ )
365
+ except (OSError, ValueError) as exc:
366
+ return {
367
+ "status": "error",
368
+ "shell": shell_label,
369
+ "message": str(exc),
370
+ }
371
+
372
+ out_capture = _Capture(max_output_chars)
373
+ err_capture = _Capture(max_output_chars)
374
+ assert process.stdout is not None and process.stderr is not None
375
+ readers = [
376
+ threading.Thread(
377
+ target=_pump,
378
+ args=(process.stdout, out_capture),
379
+ daemon=True,
380
+ ),
381
+ threading.Thread(
382
+ target=_pump,
383
+ args=(process.stderr, err_capture),
384
+ daemon=True,
385
+ ),
386
+ ]
387
+ try:
388
+ # A reader that cannot start (thread exhaustion) must still
389
+ # terminate the child: an exception raised here used to escape
390
+ # while the process tree kept running.
391
+ for reader in readers:
392
+ reader.start()
393
+ returncode = process.wait(timeout=timeout)
394
+ except subprocess.TimeoutExpired:
395
+ _terminate_tree(process)
396
+ _run_cleanup(on_timeout)
397
+ _join_readers(readers, 2.0)
398
+ stdout, stdout_truncated = _finish_capture(
399
+ out_capture, max_output_chars
400
+ )
401
+ stderr, stderr_truncated = _finish_capture(
402
+ err_capture, max_output_chars
403
+ )
404
+ return {
405
+ "status": "timeout",
406
+ "shell": shell_label,
407
+ "timeout_seconds": timeout,
408
+ "stdout": stdout.rstrip(),
409
+ "stderr": stderr.rstrip(),
410
+ "truncated": stdout_truncated or stderr_truncated,
411
+ }
412
+ except BaseException:
413
+ # Ctrl-C must not leave the command's process tree running.
414
+ _terminate_tree(process)
415
+ _run_cleanup(on_timeout)
416
+ _join_readers(readers, 2.0)
417
+ raise
418
+ _join_readers(readers, 5.0)
419
+ except (OSError, ValueError) as exc:
420
+ return {
421
+ "status": "error",
422
+ "shell": shell_label,
423
+ "message": str(exc),
424
+ }
425
+
426
+ stdout, stdout_truncated = _finish_capture(out_capture, max_output_chars)
427
+ stderr, stderr_truncated = _finish_capture(err_capture, max_output_chars)
428
+ return {
429
+ "status": "success" if returncode == 0 else "error",
430
+ "shell": shell_label,
431
+ "exit_code": returncode,
432
+ "stdout": stdout.rstrip(),
433
+ "stderr": stderr.rstrip(),
434
+ "truncated": stdout_truncated or stderr_truncated,
435
+ }