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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- 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
|
+
}
|