flowent 0.1.4 → 0.2.0

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 (68) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/agent.py +117 -34
  23. package/backend/src/flowent/approval.py +148 -0
  24. package/backend/src/flowent/cli.py +4 -2
  25. package/backend/src/flowent/context.py +19 -1
  26. package/backend/src/flowent/llm.py +176 -16
  27. package/backend/src/flowent/logging.py +60 -0
  28. package/backend/src/flowent/main.py +639 -210
  29. package/backend/src/flowent/patch.py +55 -31
  30. package/backend/src/flowent/permissions.py +185 -42
  31. package/backend/src/flowent/sandbox.py +55 -1
  32. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +82 -0
  33. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +2 -0
  34. package/backend/src/flowent/static/index.html +2 -2
  35. package/backend/src/flowent/storage.py +113 -18
  36. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/conftest.py +39 -0
  51. package/backend/tests/test_agent_tools.py +213 -1
  52. package/backend/tests/test_approval.py +283 -0
  53. package/backend/tests/test_llm_providers.py +377 -0
  54. package/backend/tests/test_logging.py +30 -0
  55. package/backend/tests/test_patch.py +112 -0
  56. package/backend/tests/test_permissions.py +198 -53
  57. package/backend/tests/test_persistence.py +78 -0
  58. package/backend/tests/test_startup_requirements.py +54 -0
  59. package/backend/tests/test_workspace_chat.py +902 -36
  60. package/backend/uv.lock +1 -1
  61. package/dist/frontend/assets/index-BlaCigkZ.js +82 -0
  62. package/dist/frontend/assets/index-CRvbsH4K.css +2 -0
  63. package/dist/frontend/index.html +2 -2
  64. package/package.json +1 -1
  65. package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
  66. package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
  67. package/dist/frontend/assets/index-BREidonU.css +0 -2
  68. package/dist/frontend/assets/index-DSniOrhL.js +0 -81
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
+ from typing import Literal
6
7
 
7
8
 
8
9
  class PatchError(RuntimeError):
@@ -16,6 +17,12 @@ class PatchChange:
16
17
  move_path: Path | None = None
17
18
 
18
19
 
20
+ @dataclass(frozen=True)
21
+ class PatchLine:
22
+ kind: Literal["context", "remove", "add"]
23
+ text: str
24
+
25
+
19
26
  def affected_paths(patch: str, cwd: Path) -> list[Path]:
20
27
  paths: list[Path] = []
21
28
  for line in patch.splitlines():
@@ -73,24 +80,29 @@ def parse_patch(patch: str, cwd: Path) -> list[dict[str, object]]:
73
80
  strict=False
74
81
  )
75
82
  index += 1
76
- chunks: list[dict[str, list[str]]] = []
77
- current: dict[str, list[str]] | None = None
83
+ chunks: list[dict[str, list[PatchLine]]] = []
84
+ current: dict[str, list[PatchLine]] | None = None
78
85
  while index < len(lines) - 1 and not lines[index].startswith("*** "):
79
86
  content_line = lines[index]
80
87
  if content_line.startswith("@@"):
81
- current = {"remove": [], "add": []}
88
+ current = {"lines": []}
82
89
  chunks.append(current)
83
90
  elif content_line.startswith("-"):
84
91
  if current is None:
85
- current = {"remove": [], "add": []}
92
+ current = {"lines": []}
86
93
  chunks.append(current)
87
- current["remove"].append(content_line[1:])
94
+ current["lines"].append(PatchLine("remove", content_line[1:]))
88
95
  elif content_line.startswith("+"):
89
96
  if current is None:
90
- current = {"remove": [], "add": []}
97
+ current = {"lines": []}
91
98
  chunks.append(current)
92
- current["add"].append(content_line[1:])
93
- elif content_line.startswith(" ") or content_line == "*** End of File":
99
+ current["lines"].append(PatchLine("add", content_line[1:]))
100
+ elif content_line.startswith(" "):
101
+ if current is None:
102
+ current = {"lines": []}
103
+ chunks.append(current)
104
+ current["lines"].append(PatchLine("context", content_line[1:]))
105
+ elif content_line == "*** End of File":
94
106
  pass
95
107
  else:
96
108
  raise PatchError(
@@ -113,30 +125,42 @@ def parse_patch(patch: str, cwd: Path) -> list[dict[str, object]]:
113
125
  return operations
114
126
 
115
127
 
116
- def apply_update(original: str, chunks: list[dict[str, list[str]]]) -> str:
117
- content = original
128
+ def find_lines(haystack: list[str], needle: list[str], start: int) -> int:
129
+ if not needle:
130
+ return len(haystack)
131
+ last_start = len(haystack) - len(needle)
132
+ for index in range(start, last_start + 1):
133
+ if haystack[index : index + len(needle)] == needle:
134
+ return index
135
+ return -1
136
+
137
+
138
+ def apply_update(original: str, chunks: list[dict[str, list[PatchLine]]]) -> str:
139
+ lines = original.splitlines()
140
+ trailing_newline = original.endswith("\n")
141
+ cursor = 0
118
142
  for chunk in chunks:
119
- remove = "\n".join(chunk["remove"])
120
- add = "\n".join(chunk["add"])
121
- if remove:
122
- candidates = [remove, remove + "\n"]
123
- for candidate in candidates:
124
- if candidate in content:
125
- replacement = add + (
126
- "\n" if candidate.endswith("\n") and add else ""
127
- )
128
- content = content.replace(candidate, replacement, 1)
129
- break
130
- else:
131
- raise PatchError("Patch context was not found.")
132
- elif add:
133
- content = (
134
- content
135
- + ("" if content.endswith("\n") or not content else "\n")
136
- + add
137
- + "\n"
138
- )
139
- return content
143
+ patch_lines = chunk["lines"]
144
+ old_lines = [
145
+ line.text for line in patch_lines if line.kind in {"context", "remove"}
146
+ ]
147
+ new_lines = [
148
+ line.text for line in patch_lines if line.kind in {"context", "add"}
149
+ ]
150
+ if not old_lines:
151
+ lines.extend(new_lines)
152
+ cursor = len(lines)
153
+ if new_lines:
154
+ trailing_newline = True
155
+ continue
156
+ match_index = find_lines(lines, old_lines, cursor)
157
+ if match_index == -1:
158
+ raise PatchError("Patch context was not found.")
159
+ lines[match_index : match_index + len(old_lines)] = new_lines
160
+ cursor = match_index + len(new_lines)
161
+ if not lines:
162
+ return ""
163
+ return "\n".join(lines) + ("\n" if trailing_newline else "")
140
164
 
141
165
 
142
166
  def apply_patch(patch: str, cwd: Path) -> dict[str, object]:
@@ -2,12 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import sys
5
- from collections.abc import Awaitable, Callable
6
5
  from pathlib import Path
7
6
  from typing import Literal
8
7
 
9
- from pydantic import BaseModel, ConfigDict
10
-
8
+ from flowent.approval import (
9
+ ApprovalReviewDecision,
10
+ ApprovalReviewer,
11
+ ApprovalReviewRequest,
12
+ )
11
13
  from flowent.patch import affected_paths
12
14
  from flowent.sandbox import SandboxError, SandboxRunner, path_is_within
13
15
  from flowent.tools import (
@@ -22,16 +24,6 @@ from flowent.tools import (
22
24
  SANDBOX_WITH_ADDITIONAL_PERMISSIONS = "with_additional_permissions"
23
25
 
24
26
 
25
- class WritablePathDecision(BaseModel):
26
- model_config = ConfigDict(extra="forbid")
27
-
28
- decision: Literal["allow_once", "always_allow", "deny"]
29
- path: Path
30
-
31
-
32
- WritablePathRequest = Callable[[Path, str], Awaitable[WritablePathDecision]]
33
-
34
-
35
27
  def normalize_path(path: Path | str, cwd: Path) -> Path:
36
28
  resolved = Path(path).expanduser()
37
29
  if not resolved.is_absolute():
@@ -83,6 +75,15 @@ def validate_additional_permissions(arguments: dict[str, object]) -> ToolResult
83
75
  return None
84
76
 
85
77
 
78
+ def approval_denial_content(decision: ApprovalReviewDecision) -> str:
79
+ return (
80
+ "Automatic approval review denied this action as high risk: "
81
+ f"{decision.reason} The agent must not work around this denial; choose a "
82
+ "safer alternative or ask the user for explicit approval after explaining "
83
+ "the concrete risk."
84
+ )
85
+
86
+
86
87
  def approved_writable_roots(
87
88
  context: ToolContext, writable_paths: list[Path]
88
89
  ) -> list[Path]:
@@ -94,33 +95,51 @@ def approved_writable_roots(
94
95
  return roots
95
96
 
96
97
 
97
- async def request_missing_write_paths(
98
+ async def review_missing_write_paths(
98
99
  paths: list[Path],
99
100
  context: ToolContext,
100
101
  *,
101
- request_writable_path: WritablePathRequest,
102
+ arguments: dict[str, object],
103
+ action: Literal["additional_permissions", "edit"],
104
+ review_approval: ApprovalReviewer,
105
+ tool_name: str,
102
106
  writable_paths: list[Path],
103
- reason: str,
104
- ) -> tuple[list[Path], ToolResult | None]:
107
+ ) -> tuple[list[Path], ToolResult | None, dict[str, object] | None]:
105
108
  effective_paths = [
106
109
  path.expanduser().resolve(strict=False) for path in writable_paths
107
110
  ]
108
111
  approved_roots = approved_writable_roots(context, effective_paths)
109
- for path in paths:
110
- if path_is_within(path, approved_roots):
111
- continue
112
- decision = await request_writable_path(path, reason)
113
- if decision.decision == "deny":
114
- return effective_paths, ToolResult(
115
- content=f"Permission denied for {decision.path}",
116
- data={"path": str(decision.path)},
112
+ missing_paths = [
113
+ path.expanduser().resolve(strict=False)
114
+ for path in paths
115
+ if not path_is_within(path, approved_roots)
116
+ ]
117
+ if not missing_paths:
118
+ return effective_paths, None, None
119
+ review_request = ApprovalReviewRequest(
120
+ action=action,
121
+ arguments=arguments,
122
+ cwd=context.cwd,
123
+ tool_name=tool_name,
124
+ write_paths=missing_paths,
125
+ )
126
+ decision = await review_approval(review_request)
127
+ review_data = approval_result_data(review_request, decision)
128
+ if decision.decision == "denied":
129
+ return (
130
+ effective_paths,
131
+ ToolResult(
132
+ content=approval_denial_content(decision),
133
+ data=review_data,
117
134
  ok=False,
118
- title=f"Denied {decision.path}",
119
- )
120
- approved_path = decision.path.expanduser().resolve(strict=False)
135
+ title="Denied by reviewer",
136
+ ),
137
+ review_data,
138
+ )
139
+ for approved_path in missing_paths:
121
140
  effective_paths.append(approved_path)
122
141
  approved_roots.append(approved_path)
123
- return effective_paths, None
142
+ return effective_paths, None, review_data
124
143
 
125
144
 
126
145
  async def run_tool_with_path_permissions(
@@ -128,21 +147,21 @@ async def run_tool_with_path_permissions(
128
147
  arguments: dict[str, object],
129
148
  context: ToolContext,
130
149
  *,
131
- request_writable_path: WritablePathRequest,
150
+ review_approval: ApprovalReviewer,
132
151
  writable_paths: list[Path],
133
152
  ) -> ToolResult:
134
153
  if name == "shell_command":
135
154
  return await run_shell_command_with_permissions(
136
155
  arguments,
137
156
  context,
138
- request_writable_path=request_writable_path,
157
+ review_approval=review_approval,
139
158
  writable_paths=writable_paths,
140
159
  )
141
160
  if name == "apply_patch":
142
161
  return await run_apply_patch_with_permissions(
143
162
  arguments,
144
163
  context,
145
- request_writable_path=request_writable_path,
164
+ review_approval=review_approval,
146
165
  writable_paths=writable_paths,
147
166
  )
148
167
  return await run_tool_async(name, arguments, context)
@@ -152,7 +171,7 @@ async def run_shell_command_with_permissions(
152
171
  arguments: dict[str, object],
153
172
  context: ToolContext,
154
173
  *,
155
- request_writable_path: WritablePathRequest,
174
+ review_approval: ApprovalReviewer,
156
175
  writable_paths: list[Path],
157
176
  ) -> ToolResult:
158
177
  validation_error = validate_additional_permissions(arguments)
@@ -160,41 +179,72 @@ async def run_shell_command_with_permissions(
160
179
  return validation_error
161
180
 
162
181
  declared_paths = additional_write_paths(arguments, context.cwd)
163
- effective_paths, denied = await request_missing_write_paths(
182
+ effective_paths, denied, approval_data = await review_missing_write_paths(
164
183
  declared_paths,
165
184
  context,
166
- request_writable_path=request_writable_path,
185
+ action="additional_permissions",
186
+ arguments=arguments,
187
+ review_approval=review_approval,
188
+ tool_name="shell_command",
167
189
  writable_paths=writable_paths,
168
- reason="The shell command needs to write this path.",
169
190
  )
170
191
  if denied is not None:
171
192
  return denied
172
193
 
173
- return await shell_command_with_writable_paths(arguments, context, effective_paths)
194
+ result = await shell_command_with_writable_paths(
195
+ arguments, context, effective_paths
196
+ )
197
+ if approval_data is not None:
198
+ result = tool_result_with_data(result, approval_data)
199
+ if result.ok or not is_likely_sandbox_denied_result(result):
200
+ return result
201
+ review_request = ApprovalReviewRequest(
202
+ action="sandbox_failure",
203
+ arguments=arguments,
204
+ cwd=context.cwd,
205
+ tool_name="shell_command",
206
+ tool_result=tool_failure_text(result),
207
+ )
208
+ decision = await review_approval(review_request)
209
+ review_data = approval_result_data(review_request, decision)
210
+ if decision.decision == "denied":
211
+ return ToolResult(
212
+ content=approval_denial_content(decision),
213
+ data={**result.data, **review_data},
214
+ ok=False,
215
+ title="Denied by reviewer",
216
+ )
217
+ retry_result = await shell_command_without_sandbox(arguments, context)
218
+ return tool_result_with_data(retry_result, review_data)
174
219
 
175
220
 
176
221
  async def run_apply_patch_with_permissions(
177
222
  arguments: dict[str, object],
178
223
  context: ToolContext,
179
224
  *,
180
- request_writable_path: WritablePathRequest,
225
+ review_approval: ApprovalReviewer,
181
226
  writable_paths: list[Path],
182
227
  ) -> ToolResult:
183
228
  patch = str(arguments["patch"])
184
229
  paths = [
185
230
  writable_root_for_path(path) for path in affected_paths(patch, context.cwd)
186
231
  ]
187
- effective_paths, denied = await request_missing_write_paths(
232
+ effective_paths, denied, approval_data = await review_missing_write_paths(
188
233
  paths,
189
234
  context,
190
- request_writable_path=request_writable_path,
235
+ action="edit",
236
+ arguments=arguments,
237
+ review_approval=review_approval,
238
+ tool_name="apply_patch",
191
239
  writable_paths=writable_paths,
192
- reason="The edit needs to write this path.",
193
240
  )
194
241
  if denied is not None:
195
242
  return denied
196
243
 
197
- return await apply_patch_with_writable_paths(arguments, context, effective_paths)
244
+ result = await apply_patch_with_writable_paths(arguments, context, effective_paths)
245
+ if approval_data is not None:
246
+ result = tool_result_with_data(result, approval_data)
247
+ return result
198
248
 
199
249
 
200
250
  async def apply_patch_with_writable_paths(
@@ -257,3 +307,96 @@ async def shell_command_with_writable_paths(
257
307
  ok=ok,
258
308
  title=f"Ran {command}",
259
309
  )
310
+
311
+
312
+ def is_likely_sandbox_denied_result(result: ToolResult) -> bool:
313
+ data = result.data
314
+ exit_code = int_result_field(data.get("exit_code"))
315
+ if exit_code == 0:
316
+ return False
317
+ output = "\n".join(
318
+ str(data.get(name, "") or "") for name in ["stderr", "stdout"]
319
+ ).lower()
320
+ return any(
321
+ keyword in output
322
+ for keyword in [
323
+ "operation not permitted",
324
+ "permission denied",
325
+ "read-only file system",
326
+ "seccomp",
327
+ "sandbox",
328
+ "landlock",
329
+ "failed to write file",
330
+ ]
331
+ )
332
+
333
+
334
+ def int_result_field(value: object) -> int:
335
+ if isinstance(value, int):
336
+ return value
337
+ if isinstance(value, str):
338
+ try:
339
+ return int(value)
340
+ except ValueError:
341
+ return 0
342
+ return 0
343
+
344
+
345
+ def tool_failure_text(result: ToolResult) -> str:
346
+ stderr = str(result.data.get("stderr", "") or "").strip()
347
+ stdout = str(result.data.get("stdout", "") or "").strip()
348
+ content = result.content.strip()
349
+ return "\n".join(part for part in [stderr, stdout, content] if part)
350
+
351
+
352
+ async def shell_command_without_sandbox(
353
+ arguments: dict[str, object],
354
+ context: ToolContext,
355
+ ) -> ToolResult:
356
+ command = str(arguments["command"])
357
+ timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
358
+ result = await SandboxRunner(cwd=context.cwd).run_unsandboxed_async(
359
+ ["/bin/sh", "-c", command], timeout_seconds=timeout_seconds
360
+ )
361
+ ok = result.exit_code == 0
362
+ content = result.stdout or result.stderr
363
+ return ToolResult(
364
+ content=content,
365
+ data={
366
+ "command": command,
367
+ "exit_code": result.exit_code,
368
+ "stderr": result.stderr,
369
+ "stdout": result.stdout,
370
+ },
371
+ ok=ok,
372
+ title=f"Ran {command}",
373
+ )
374
+
375
+
376
+ def approval_result_data(
377
+ request: ApprovalReviewRequest,
378
+ decision: ApprovalReviewDecision,
379
+ ) -> dict[str, object]:
380
+ approval: dict[str, object] = {
381
+ "action": request.action,
382
+ "decision": decision.decision,
383
+ "reason": decision.reason,
384
+ "tool_name": request.tool_name,
385
+ "tool_result": request.tool_result,
386
+ "write_paths": [str(path) for path in request.write_paths],
387
+ }
388
+ if decision.risk_level is not None:
389
+ approval["risk_level"] = decision.risk_level
390
+ if decision.risk_score is not None:
391
+ approval["risk_score"] = decision.risk_score
392
+ if decision.evidence:
393
+ approval["evidence"] = [item.model_dump() for item in decision.evidence]
394
+ return {
395
+ "approval": approval,
396
+ }
397
+
398
+
399
+ def tool_result_with_data(
400
+ result: ToolResult, extra_data: dict[str, object]
401
+ ) -> ToolResult:
402
+ return result.model_copy(update={"data": {**result.data, **extra_data}})
@@ -261,7 +261,8 @@ class SandboxRunner:
261
261
  for root in self.writable_roots:
262
262
  if root == self.cwd:
263
263
  continue
264
- root.mkdir(mode=0o700, parents=True, exist_ok=True)
264
+ if not root.exists():
265
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
265
266
  args.extend(["--bind", str(root), str(root)])
266
267
  args.extend(
267
268
  [
@@ -327,6 +328,59 @@ class SandboxRunner:
327
328
  stdout=completed.stdout[: self.output_limit],
328
329
  )
329
330
 
331
+ async def run_unsandboxed_async(
332
+ self,
333
+ command: list[str],
334
+ *,
335
+ env: dict[str, str] | None = None,
336
+ input_text: str | None = None,
337
+ timeout_seconds: float | None = None,
338
+ ) -> CommandResult:
339
+ process_env = build_shell_environment(env)
340
+ process = await asyncio.create_subprocess_exec(
341
+ *command,
342
+ cwd=self.cwd,
343
+ env=process_env,
344
+ start_new_session=True,
345
+ stdin=asyncio.subprocess.PIPE if input_text is not None else None,
346
+ stdout=asyncio.subprocess.PIPE,
347
+ stderr=asyncio.subprocess.PIPE,
348
+ )
349
+ try:
350
+ stdout, stderr = await asyncio.wait_for(
351
+ process.communicate(
352
+ input_text.encode() if input_text is not None else None
353
+ ),
354
+ timeout=timeout_seconds or self.timeout_seconds,
355
+ )
356
+ except TimeoutError as error:
357
+ with suppress(ProcessLookupError):
358
+ os.killpg(process.pid, signal.SIGKILL)
359
+ stdout, stderr = await process.communicate()
360
+ return CommandResult(
361
+ command=" ".join(command),
362
+ exit_code=124,
363
+ stderr=str(error) or "Command timed out.",
364
+ stdout=self._text_output(stdout)[: self.output_limit],
365
+ )
366
+ except asyncio.CancelledError:
367
+ with suppress(ProcessLookupError):
368
+ os.killpg(process.pid, signal.SIGTERM)
369
+ try:
370
+ await asyncio.wait_for(process.wait(), timeout=1)
371
+ except TimeoutError:
372
+ with suppress(ProcessLookupError):
373
+ os.killpg(process.pid, signal.SIGKILL)
374
+ await process.wait()
375
+ raise
376
+
377
+ return CommandResult(
378
+ command=" ".join(command),
379
+ exit_code=process.returncode or 0,
380
+ stderr=self._text_output(stderr)[: self.output_limit],
381
+ stdout=self._text_output(stdout)[: self.output_limit],
382
+ )
383
+
330
384
  async def run_async(
331
385
  self,
332
386
  command: list[str],