flowent 0.1.3 → 0.1.5
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.
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +23 -1
- package/backend/src/flowent/approval.py +148 -0
- package/backend/src/flowent/cli.py +16 -2
- package/backend/src/flowent/compact.py +183 -0
- package/backend/src/flowent/context.py +19 -1
- package/backend/src/flowent/llm.py +51 -11
- package/backend/src/flowent/logging.py +60 -0
- package/backend/src/flowent/main.py +696 -192
- package/backend/src/flowent/mcp.py +3 -1
- package/backend/src/flowent/patch.py +55 -31
- package/backend/src/flowent/paths.py +12 -0
- package/backend/src/flowent/permissions.py +185 -42
- package/backend/src/flowent/sandbox.py +146 -13
- package/backend/src/flowent/static/assets/index-Cl20cARb.css +2 -0
- package/backend/src/flowent/static/assets/index-dsDDsEym.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +257 -9
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/test_agent_tools.py +312 -1
- package/backend/tests/test_approval.py +283 -0
- package/backend/tests/test_llm_providers.py +216 -0
- package/backend/tests/test_logging.py +30 -0
- package/backend/tests/test_mcp.py +76 -10
- package/backend/tests/test_patch.py +112 -0
- package/backend/tests/test_permissions.py +198 -53
- package/backend/tests/test_persistence.py +78 -0
- package/backend/tests/test_startup_requirements.py +96 -0
- package/backend/tests/test_workspace_chat.py +1265 -144
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-Cl20cARb.css +2 -0
- package/dist/frontend/assets/index-dsDDsEym.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +2 -2
- package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
- package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
- package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
|
@@ -3,8 +3,9 @@ from pathlib import Path
|
|
|
3
3
|
import pytest
|
|
4
4
|
from fastapi.testclient import TestClient
|
|
5
5
|
|
|
6
|
+
from flowent.approval import ApprovalReviewDecision, ApprovalReviewRequest
|
|
6
7
|
from flowent.main import create_app
|
|
7
|
-
from flowent.permissions import
|
|
8
|
+
from flowent.permissions import run_tool_with_path_permissions
|
|
8
9
|
from flowent.sandbox import CommandResult, SandboxRunner
|
|
9
10
|
from flowent.storage import StateStore
|
|
10
11
|
from flowent.tools import ToolContext
|
|
@@ -63,11 +64,12 @@ def test_writable_paths_are_saved_as_normalized_absolute_paths(
|
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
@pytest.mark.anyio
|
|
66
|
-
async def
|
|
67
|
+
async def test_approved_declared_write_path_runs_command_with_extra_permission(
|
|
67
68
|
tmp_path, monkeypatch
|
|
68
69
|
) -> None:
|
|
69
70
|
cache_dir = tmp_path / "cache"
|
|
70
71
|
calls: list[list[Path]] = []
|
|
72
|
+
reviews: list[ApprovalReviewRequest] = []
|
|
71
73
|
|
|
72
74
|
async def fake_run_async(self, command, **kwargs):
|
|
73
75
|
calls.append(self.writable_roots)
|
|
@@ -78,10 +80,11 @@ async def test_allow_once_runs_tool_with_declared_write_path(
|
|
|
78
80
|
stdout="created",
|
|
79
81
|
)
|
|
80
82
|
|
|
81
|
-
async def approve(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
84
|
+
reviews.append(request)
|
|
85
|
+
return ApprovalReviewDecision(
|
|
86
|
+
decision="approved", reason="Needed for cache writes."
|
|
87
|
+
)
|
|
85
88
|
|
|
86
89
|
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
87
90
|
|
|
@@ -93,7 +96,7 @@ async def test_allow_once_runs_tool_with_declared_write_path(
|
|
|
93
96
|
"sandbox_permissions": "with_additional_permissions",
|
|
94
97
|
},
|
|
95
98
|
ToolContext(cwd=tmp_path / "work"),
|
|
96
|
-
|
|
99
|
+
review_approval=approve,
|
|
97
100
|
writable_paths=[],
|
|
98
101
|
)
|
|
99
102
|
|
|
@@ -101,10 +104,21 @@ async def test_allow_once_runs_tool_with_declared_write_path(
|
|
|
101
104
|
assert result.content == "created"
|
|
102
105
|
assert len(calls) == 1
|
|
103
106
|
assert cache_dir in calls[0]
|
|
107
|
+
assert reviews[0].tool_name == "shell_command"
|
|
108
|
+
assert reviews[0].action == "additional_permissions"
|
|
109
|
+
assert reviews[0].write_paths == [cache_dir]
|
|
110
|
+
assert result.data["approval"] == {
|
|
111
|
+
"action": "additional_permissions",
|
|
112
|
+
"decision": "approved",
|
|
113
|
+
"reason": "Needed for cache writes.",
|
|
114
|
+
"tool_name": "shell_command",
|
|
115
|
+
"tool_result": "",
|
|
116
|
+
"write_paths": [str(cache_dir)],
|
|
117
|
+
}
|
|
104
118
|
|
|
105
119
|
|
|
106
120
|
@pytest.mark.anyio
|
|
107
|
-
async def
|
|
121
|
+
async def test_approved_declared_write_path_does_not_persist_runtime_permission(
|
|
108
122
|
tmp_path, monkeypatch
|
|
109
123
|
) -> None:
|
|
110
124
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
@@ -120,9 +134,10 @@ async def test_always_allow_runs_tool_and_persists_declared_path(
|
|
|
120
134
|
stdout="created",
|
|
121
135
|
)
|
|
122
136
|
|
|
123
|
-
async def approve(
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
138
|
+
return ApprovalReviewDecision(
|
|
139
|
+
decision="approved", reason="Needed for cache writes."
|
|
140
|
+
)
|
|
126
141
|
|
|
127
142
|
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
128
143
|
|
|
@@ -134,16 +149,16 @@ async def test_always_allow_runs_tool_and_persists_declared_path(
|
|
|
134
149
|
"sandbox_permissions": "with_additional_permissions",
|
|
135
150
|
},
|
|
136
151
|
ToolContext(cwd=tmp_path / "work"),
|
|
137
|
-
|
|
152
|
+
review_approval=approve,
|
|
138
153
|
writable_paths=[],
|
|
139
154
|
)
|
|
140
155
|
|
|
141
156
|
assert result.ok
|
|
142
|
-
assert
|
|
157
|
+
assert store.read_writable_paths() == []
|
|
143
158
|
|
|
144
159
|
|
|
145
160
|
@pytest.mark.anyio
|
|
146
|
-
async def
|
|
161
|
+
async def test_denied_declared_write_path_returns_failed_result_before_running_command(
|
|
147
162
|
tmp_path, monkeypatch
|
|
148
163
|
) -> None:
|
|
149
164
|
cache_dir = tmp_path / "cache"
|
|
@@ -159,8 +174,10 @@ async def test_deny_returns_failed_tool_result_before_running_command(
|
|
|
159
174
|
stdout="created",
|
|
160
175
|
)
|
|
161
176
|
|
|
162
|
-
async def deny(
|
|
163
|
-
return
|
|
177
|
+
async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
178
|
+
return ApprovalReviewDecision(
|
|
179
|
+
decision="denied", reason="Outside the task scope."
|
|
180
|
+
)
|
|
164
181
|
|
|
165
182
|
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
166
183
|
|
|
@@ -172,17 +189,21 @@ async def test_deny_returns_failed_tool_result_before_running_command(
|
|
|
172
189
|
"sandbox_permissions": "with_additional_permissions",
|
|
173
190
|
},
|
|
174
191
|
ToolContext(cwd=tmp_path / "work"),
|
|
175
|
-
|
|
192
|
+
review_approval=deny,
|
|
176
193
|
writable_paths=[],
|
|
177
194
|
)
|
|
178
195
|
|
|
179
196
|
assert not result.ok
|
|
180
|
-
assert "
|
|
197
|
+
assert "Automatic approval review denied this action" in result.content
|
|
198
|
+
assert "Outside the task scope." in result.content
|
|
199
|
+
assert "must not work around" in result.content
|
|
200
|
+
assert result.data["approval"]["decision"] == "denied"
|
|
201
|
+
assert result.data["approval"]["reason"] == "Outside the task scope."
|
|
181
202
|
assert calls == 0
|
|
182
203
|
|
|
183
204
|
|
|
184
205
|
@pytest.mark.anyio
|
|
185
|
-
async def
|
|
206
|
+
async def test_existing_writable_path_covers_declared_review(
|
|
186
207
|
tmp_path, monkeypatch
|
|
187
208
|
) -> None:
|
|
188
209
|
cache_dir = tmp_path / "cache"
|
|
@@ -197,10 +218,10 @@ async def test_existing_writable_path_covers_declared_permission_request(
|
|
|
197
218
|
stdout="created",
|
|
198
219
|
)
|
|
199
220
|
|
|
200
|
-
async def approve(
|
|
221
|
+
async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
201
222
|
nonlocal requests
|
|
202
223
|
requests += 1
|
|
203
|
-
return
|
|
224
|
+
return ApprovalReviewDecision(decision="approved", reason="Already allowed.")
|
|
204
225
|
|
|
205
226
|
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
206
227
|
|
|
@@ -212,7 +233,7 @@ async def test_existing_writable_path_covers_declared_permission_request(
|
|
|
212
233
|
"sandbox_permissions": "with_additional_permissions",
|
|
213
234
|
},
|
|
214
235
|
ToolContext(cwd=tmp_path / "work"),
|
|
215
|
-
|
|
236
|
+
review_approval=approve,
|
|
216
237
|
writable_paths=[cache_dir],
|
|
217
238
|
)
|
|
218
239
|
|
|
@@ -226,7 +247,7 @@ async def test_multiple_declared_write_paths_request_each_missing_path(
|
|
|
226
247
|
) -> None:
|
|
227
248
|
first = tmp_path / "cache"
|
|
228
249
|
second = tmp_path / "downloads"
|
|
229
|
-
|
|
250
|
+
reviews: list[ApprovalReviewRequest] = []
|
|
230
251
|
|
|
231
252
|
async def fake_run_async(self, command, **kwargs):
|
|
232
253
|
assert first in self.writable_roots
|
|
@@ -238,9 +259,11 @@ async def test_multiple_declared_write_paths_request_each_missing_path(
|
|
|
238
259
|
stdout="created",
|
|
239
260
|
)
|
|
240
261
|
|
|
241
|
-
async def approve(
|
|
242
|
-
|
|
243
|
-
return
|
|
262
|
+
async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
263
|
+
reviews.append(request)
|
|
264
|
+
return ApprovalReviewDecision(
|
|
265
|
+
decision="approved", reason="Needed for generated files."
|
|
266
|
+
)
|
|
244
267
|
|
|
245
268
|
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
246
269
|
|
|
@@ -254,20 +277,126 @@ async def test_multiple_declared_write_paths_request_each_missing_path(
|
|
|
254
277
|
"sandbox_permissions": "with_additional_permissions",
|
|
255
278
|
},
|
|
256
279
|
ToolContext(cwd=tmp_path / "work"),
|
|
257
|
-
|
|
280
|
+
review_approval=approve,
|
|
281
|
+
writable_paths=[],
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
assert result.ok
|
|
285
|
+
assert len(reviews) == 1
|
|
286
|
+
assert reviews[0].write_paths == [first, second]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@pytest.mark.anyio
|
|
290
|
+
async def test_sandbox_denied_shell_command_is_reviewed_and_retried_without_sandbox(
|
|
291
|
+
tmp_path, monkeypatch
|
|
292
|
+
) -> None:
|
|
293
|
+
calls: list[str] = []
|
|
294
|
+
reviews: list[ApprovalReviewRequest] = []
|
|
295
|
+
|
|
296
|
+
async def fake_run_async(self, command, **kwargs):
|
|
297
|
+
calls.append("sandbox")
|
|
298
|
+
return CommandResult(
|
|
299
|
+
command=" ".join(command),
|
|
300
|
+
exit_code=1,
|
|
301
|
+
stderr="failed to write file: Read-only file system\n",
|
|
302
|
+
stdout="",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
async def fake_run_unsandboxed_async(self, command, **kwargs):
|
|
306
|
+
calls.append("unsandboxed")
|
|
307
|
+
return CommandResult(
|
|
308
|
+
command=" ".join(command),
|
|
309
|
+
exit_code=0,
|
|
310
|
+
stderr="",
|
|
311
|
+
stdout="created",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
315
|
+
reviews.append(request)
|
|
316
|
+
return ApprovalReviewDecision(
|
|
317
|
+
decision="approved", reason="Retry is consistent with the task."
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
321
|
+
monkeypatch.setattr(
|
|
322
|
+
SandboxRunner,
|
|
323
|
+
"run_unsandboxed_async",
|
|
324
|
+
fake_run_unsandboxed_async,
|
|
325
|
+
raising=False,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
result = await run_tool_with_path_permissions(
|
|
329
|
+
"shell_command",
|
|
330
|
+
{"command": "touch output.txt"},
|
|
331
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
332
|
+
review_approval=approve,
|
|
258
333
|
writable_paths=[],
|
|
259
334
|
)
|
|
260
335
|
|
|
261
336
|
assert result.ok
|
|
262
|
-
assert
|
|
337
|
+
assert result.content == "created"
|
|
338
|
+
assert calls == ["sandbox", "unsandboxed"]
|
|
339
|
+
assert reviews[0].action == "sandbox_failure"
|
|
340
|
+
assert "Read-only file system" in reviews[0].tool_result
|
|
341
|
+
assert result.data["approval"]["action"] == "sandbox_failure"
|
|
342
|
+
assert result.data["approval"]["decision"] == "approved"
|
|
263
343
|
|
|
264
344
|
|
|
265
345
|
@pytest.mark.anyio
|
|
266
|
-
async def
|
|
346
|
+
async def test_sandbox_denied_shell_command_is_not_retried_when_reviewer_denies(
|
|
347
|
+
tmp_path, monkeypatch
|
|
348
|
+
) -> None:
|
|
349
|
+
calls: list[str] = []
|
|
350
|
+
|
|
351
|
+
async def fake_run_async(self, command, **kwargs):
|
|
352
|
+
calls.append("sandbox")
|
|
353
|
+
return CommandResult(
|
|
354
|
+
command=" ".join(command),
|
|
355
|
+
exit_code=1,
|
|
356
|
+
stderr="failed to write file: Read-only file system\n",
|
|
357
|
+
stdout="",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
async def fake_run_unsandboxed_async(self, command, **kwargs):
|
|
361
|
+
calls.append("unsandboxed")
|
|
362
|
+
return CommandResult(
|
|
363
|
+
command=" ".join(command),
|
|
364
|
+
exit_code=0,
|
|
365
|
+
stderr="",
|
|
366
|
+
stdout="created",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
370
|
+
return ApprovalReviewDecision(decision="denied", reason="Too broad.")
|
|
371
|
+
|
|
372
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
373
|
+
monkeypatch.setattr(
|
|
374
|
+
SandboxRunner,
|
|
375
|
+
"run_unsandboxed_async",
|
|
376
|
+
fake_run_unsandboxed_async,
|
|
377
|
+
raising=False,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
result = await run_tool_with_path_permissions(
|
|
381
|
+
"shell_command",
|
|
382
|
+
{"command": "touch output.txt"},
|
|
383
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
384
|
+
review_approval=deny,
|
|
385
|
+
writable_paths=[],
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
assert not result.ok
|
|
389
|
+
assert calls == ["sandbox"]
|
|
390
|
+
assert "Too broad." in result.content
|
|
391
|
+
assert result.data["approval"]["decision"] == "denied"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@pytest.mark.anyio
|
|
395
|
+
async def test_command_text_is_not_used_to_guess_write_paths(
|
|
267
396
|
tmp_path, monkeypatch
|
|
268
397
|
) -> None:
|
|
269
398
|
outside = tmp_path / "outside"
|
|
270
|
-
|
|
399
|
+
reviews: list[ApprovalReviewRequest] = []
|
|
271
400
|
|
|
272
401
|
async def fake_run_async(self, command, **kwargs):
|
|
273
402
|
assert outside not in self.writable_roots
|
|
@@ -278,10 +407,11 @@ async def test_command_text_is_not_used_to_guess_permissions(
|
|
|
278
407
|
stdout="",
|
|
279
408
|
)
|
|
280
409
|
|
|
281
|
-
async def
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
410
|
+
async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
411
|
+
reviews.append(request)
|
|
412
|
+
return ApprovalReviewDecision(
|
|
413
|
+
decision="denied", reason="No extra write paths were declared."
|
|
414
|
+
)
|
|
285
415
|
|
|
286
416
|
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
287
417
|
|
|
@@ -289,12 +419,13 @@ async def test_command_text_is_not_used_to_guess_permissions(
|
|
|
289
419
|
"shell_command",
|
|
290
420
|
{"command": f"rm -f {outside / 'file.txt'}"},
|
|
291
421
|
ToolContext(cwd=tmp_path / "work"),
|
|
292
|
-
|
|
422
|
+
review_approval=deny,
|
|
293
423
|
writable_paths=[],
|
|
294
424
|
)
|
|
295
425
|
|
|
296
426
|
assert not result.ok
|
|
297
|
-
assert
|
|
427
|
+
assert reviews[0].action == "sandbox_failure"
|
|
428
|
+
assert reviews[0].write_paths == []
|
|
298
429
|
|
|
299
430
|
|
|
300
431
|
@pytest.mark.anyio
|
|
@@ -303,6 +434,7 @@ async def test_additional_permissions_require_matching_sandbox_permissions(
|
|
|
303
434
|
) -> None:
|
|
304
435
|
cache_dir = tmp_path / "cache"
|
|
305
436
|
calls = 0
|
|
437
|
+
reviews = 0
|
|
306
438
|
|
|
307
439
|
async def fake_run_async(self, command, **kwargs):
|
|
308
440
|
nonlocal calls
|
|
@@ -314,8 +446,10 @@ async def test_additional_permissions_require_matching_sandbox_permissions(
|
|
|
314
446
|
stdout="created",
|
|
315
447
|
)
|
|
316
448
|
|
|
317
|
-
async def approve(
|
|
318
|
-
|
|
449
|
+
async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
450
|
+
nonlocal reviews
|
|
451
|
+
reviews += 1
|
|
452
|
+
return ApprovalReviewDecision(decision="approved", reason="Allowed.")
|
|
319
453
|
|
|
320
454
|
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
321
455
|
|
|
@@ -326,17 +460,18 @@ async def test_additional_permissions_require_matching_sandbox_permissions(
|
|
|
326
460
|
"command": f"touch {cache_dir / 'file.txt'}",
|
|
327
461
|
},
|
|
328
462
|
ToolContext(cwd=tmp_path / "work"),
|
|
329
|
-
|
|
463
|
+
review_approval=approve,
|
|
330
464
|
writable_paths=[],
|
|
331
465
|
)
|
|
332
466
|
|
|
333
467
|
assert not result.ok
|
|
334
468
|
assert "with_additional_permissions" in result.content
|
|
335
469
|
assert calls == 0
|
|
470
|
+
assert reviews == 0
|
|
336
471
|
|
|
337
472
|
|
|
338
473
|
@pytest.mark.anyio
|
|
339
|
-
async def
|
|
474
|
+
async def test_apply_patch_uses_reviewer_before_writing_outside_workdir_file(
|
|
340
475
|
tmp_path, monkeypatch
|
|
341
476
|
) -> None:
|
|
342
477
|
work_dir = tmp_path / "work"
|
|
@@ -345,11 +480,13 @@ async def test_apply_patch_requests_permission_for_outside_workdir_file(
|
|
|
345
480
|
outside_dir.mkdir()
|
|
346
481
|
target = outside_dir / "notes.txt"
|
|
347
482
|
target.write_text("alpha\n")
|
|
348
|
-
|
|
483
|
+
reviews: list[ApprovalReviewRequest] = []
|
|
349
484
|
|
|
350
|
-
async def approve(
|
|
351
|
-
|
|
352
|
-
return
|
|
485
|
+
async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
486
|
+
reviews.append(request)
|
|
487
|
+
return ApprovalReviewDecision(
|
|
488
|
+
decision="approved", reason="The edit matches the request."
|
|
489
|
+
)
|
|
353
490
|
|
|
354
491
|
patch = f"""*** Begin Patch
|
|
355
492
|
*** Update File: {target}
|
|
@@ -363,12 +500,16 @@ async def test_apply_patch_requests_permission_for_outside_workdir_file(
|
|
|
363
500
|
"apply_patch",
|
|
364
501
|
{"patch": patch},
|
|
365
502
|
ToolContext(cwd=work_dir),
|
|
366
|
-
|
|
503
|
+
review_approval=approve,
|
|
367
504
|
writable_paths=[],
|
|
368
505
|
)
|
|
369
506
|
|
|
370
507
|
assert result.ok
|
|
371
|
-
assert
|
|
508
|
+
assert reviews[0].tool_name == "apply_patch"
|
|
509
|
+
assert reviews[0].action == "edit"
|
|
510
|
+
assert reviews[0].write_paths == [outside_dir]
|
|
511
|
+
assert result.data["approval"]["action"] == "edit"
|
|
512
|
+
assert result.data["approval"]["decision"] == "approved"
|
|
372
513
|
assert target.read_text() == "beta\n"
|
|
373
514
|
|
|
374
515
|
|
|
@@ -384,10 +525,10 @@ async def test_apply_patch_uses_existing_writable_path_without_request(
|
|
|
384
525
|
target.write_text("alpha\n")
|
|
385
526
|
requests = 0
|
|
386
527
|
|
|
387
|
-
async def approve(
|
|
528
|
+
async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
388
529
|
nonlocal requests
|
|
389
530
|
requests += 1
|
|
390
|
-
return
|
|
531
|
+
return ApprovalReviewDecision(decision="approved", reason="Already allowed.")
|
|
391
532
|
|
|
392
533
|
patch = f"""*** Begin Patch
|
|
393
534
|
*** Update File: {target}
|
|
@@ -401,7 +542,7 @@ async def test_apply_patch_uses_existing_writable_path_without_request(
|
|
|
401
542
|
"apply_patch",
|
|
402
543
|
{"patch": patch},
|
|
403
544
|
ToolContext(cwd=work_dir),
|
|
404
|
-
|
|
545
|
+
review_approval=approve,
|
|
405
546
|
writable_paths=[outside_dir],
|
|
406
547
|
)
|
|
407
548
|
|
|
@@ -419,8 +560,10 @@ async def test_denied_apply_patch_does_not_modify_file(tmp_path) -> None:
|
|
|
419
560
|
target = outside_dir / "notes.txt"
|
|
420
561
|
target.write_text("alpha\n")
|
|
421
562
|
|
|
422
|
-
async def deny(
|
|
423
|
-
return
|
|
563
|
+
async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
|
|
564
|
+
return ApprovalReviewDecision(
|
|
565
|
+
decision="denied", reason="The target is outside the allowed scope."
|
|
566
|
+
)
|
|
424
567
|
|
|
425
568
|
patch = f"""*** Begin Patch
|
|
426
569
|
*** Update File: {target}
|
|
@@ -434,10 +577,12 @@ async def test_denied_apply_patch_does_not_modify_file(tmp_path) -> None:
|
|
|
434
577
|
"apply_patch",
|
|
435
578
|
{"patch": patch},
|
|
436
579
|
ToolContext(cwd=work_dir),
|
|
437
|
-
|
|
580
|
+
review_approval=deny,
|
|
438
581
|
writable_paths=[],
|
|
439
582
|
)
|
|
440
583
|
|
|
441
584
|
assert not result.ok
|
|
442
|
-
assert "
|
|
585
|
+
assert "outside the allowed scope" in result.content
|
|
586
|
+
assert result.data["approval"]["action"] == "edit"
|
|
587
|
+
assert result.data["approval"]["decision"] == "denied"
|
|
443
588
|
assert target.read_text() == "alpha\n"
|
|
@@ -89,6 +89,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
89
89
|
settings_response = client.put(
|
|
90
90
|
"/api/settings",
|
|
91
91
|
json={
|
|
92
|
+
"agent_prompt": "Respond with careful implementation plans.",
|
|
92
93
|
"reasoning_effort": "xhigh",
|
|
93
94
|
"selected_model": "claude-sonnet-4-5",
|
|
94
95
|
"selected_provider_id": "provider-anthropic",
|
|
@@ -123,6 +124,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
123
124
|
state = restarted_client.get("/api/state").json()
|
|
124
125
|
|
|
125
126
|
assert state["settings"] == {
|
|
127
|
+
"agent_prompt": "Respond with careful implementation plans.",
|
|
126
128
|
"reasoning_effort": "xhigh",
|
|
127
129
|
"selected_model": "claude-sonnet-4-5",
|
|
128
130
|
"selected_provider_id": "provider-anthropic",
|
|
@@ -148,6 +150,70 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
148
150
|
]
|
|
149
151
|
|
|
150
152
|
|
|
153
|
+
def test_app_state_persists_workspace_error_blocks_across_app_instances(
|
|
154
|
+
tmp_path, monkeypatch
|
|
155
|
+
) -> None:
|
|
156
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
157
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
158
|
+
|
|
159
|
+
messages_response = client.put(
|
|
160
|
+
"/api/workspace/messages",
|
|
161
|
+
json={
|
|
162
|
+
"messages": [
|
|
163
|
+
{
|
|
164
|
+
"author": "assistant",
|
|
165
|
+
"content": "",
|
|
166
|
+
"groups": [
|
|
167
|
+
{
|
|
168
|
+
"id": "message-1-errors",
|
|
169
|
+
"items": [
|
|
170
|
+
{
|
|
171
|
+
"detail": "HTML response returned.",
|
|
172
|
+
"id": "message-1-error-1",
|
|
173
|
+
"message": "Check the model connection settings and try again.",
|
|
174
|
+
"title": "Request failed",
|
|
175
|
+
"type": "error",
|
|
176
|
+
}
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
],
|
|
180
|
+
"id": "message-1",
|
|
181
|
+
"status": "failed",
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
assert messages_response.status_code == 200
|
|
188
|
+
|
|
189
|
+
restarted_client = TestClient(create_app(serve_frontend=False))
|
|
190
|
+
state = restarted_client.get("/api/state").json()
|
|
191
|
+
|
|
192
|
+
assert state["messages"] == [
|
|
193
|
+
{
|
|
194
|
+
"author": "assistant",
|
|
195
|
+
"content": "",
|
|
196
|
+
"groups": [
|
|
197
|
+
{
|
|
198
|
+
"id": "message-1-errors",
|
|
199
|
+
"items": [
|
|
200
|
+
{
|
|
201
|
+
"detail": "HTML response returned.",
|
|
202
|
+
"id": "message-1-error-1",
|
|
203
|
+
"message": "Check the model connection settings and try again.",
|
|
204
|
+
"title": "Request failed",
|
|
205
|
+
"type": "error",
|
|
206
|
+
}
|
|
207
|
+
],
|
|
208
|
+
}
|
|
209
|
+
],
|
|
210
|
+
"id": "message-1",
|
|
211
|
+
"status": "failed",
|
|
212
|
+
"tools": [],
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
|
|
151
217
|
def test_data_directory_uses_flowent_data_dir(tmp_path, monkeypatch) -> None:
|
|
152
218
|
data_dir = tmp_path / "custom-flowent"
|
|
153
219
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
|
|
@@ -169,3 +235,15 @@ def test_app_state_defaults_reasoning_effort_for_existing_settings(
|
|
|
169
235
|
|
|
170
236
|
assert response.status_code == 200
|
|
171
237
|
assert response.json()["settings"]["reasoning_effort"] == "default"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_app_state_defaults_agent_prompt_for_existing_settings(
|
|
241
|
+
tmp_path, monkeypatch
|
|
242
|
+
) -> None:
|
|
243
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
244
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
245
|
+
|
|
246
|
+
response = client.get("/api/state")
|
|
247
|
+
|
|
248
|
+
assert response.status_code == 200
|
|
249
|
+
assert response.json()["settings"].get("agent_prompt", "") == ""
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
1
5
|
import pytest
|
|
2
6
|
|
|
3
7
|
from flowent.cli import main
|
|
4
8
|
from flowent.main import create_app
|
|
9
|
+
from flowent.paths import WORKDIR_ENV_VAR
|
|
5
10
|
from flowent.sandbox import SandboxError
|
|
6
11
|
|
|
7
12
|
|
|
@@ -46,3 +51,94 @@ def test_doctor_reports_available_sandbox(monkeypatch, capsys) -> None:
|
|
|
46
51
|
|
|
47
52
|
assert error.value.code == 0
|
|
48
53
|
assert "Sandbox: /usr/bin/bwrap" in capsys.readouterr().out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_main_sets_workdir_for_server_start(tmp_path, monkeypatch) -> None:
|
|
57
|
+
env_workdir = tmp_path / "env-workspace"
|
|
58
|
+
workdir = tmp_path / "workspace"
|
|
59
|
+
env_workdir.mkdir()
|
|
60
|
+
workdir.mkdir()
|
|
61
|
+
calls: list[tuple[str, dict[str, object]]] = []
|
|
62
|
+
|
|
63
|
+
def fake_run(app: str, **kwargs: object) -> None:
|
|
64
|
+
calls.append((app, kwargs))
|
|
65
|
+
|
|
66
|
+
monkeypatch.setenv("FLOWENT_WORKDIR", str(env_workdir))
|
|
67
|
+
monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(run=fake_run))
|
|
68
|
+
|
|
69
|
+
main(
|
|
70
|
+
[
|
|
71
|
+
"--workdir",
|
|
72
|
+
str(workdir),
|
|
73
|
+
"--host",
|
|
74
|
+
"127.0.0.1",
|
|
75
|
+
"--port",
|
|
76
|
+
"6899",
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert os.environ["FLOWENT_WORKDIR"] == str(workdir.resolve(strict=False))
|
|
81
|
+
assert calls == [("flowent.main:app", {"host": "127.0.0.1", "port": 6899})]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_main_uses_default_host_when_environment_is_not_set(
|
|
85
|
+
tmp_path, monkeypatch
|
|
86
|
+
) -> None:
|
|
87
|
+
workdir = tmp_path / "workspace"
|
|
88
|
+
workdir.mkdir()
|
|
89
|
+
calls: list[tuple[str, dict[str, object]]] = []
|
|
90
|
+
|
|
91
|
+
def fake_run(app: str, **kwargs: object) -> None:
|
|
92
|
+
calls.append((app, kwargs))
|
|
93
|
+
|
|
94
|
+
monkeypatch.delenv("FLOWENT_HOST", raising=False)
|
|
95
|
+
monkeypatch.setenv(WORKDIR_ENV_VAR, str(workdir))
|
|
96
|
+
monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(run=fake_run))
|
|
97
|
+
|
|
98
|
+
main(["--workdir", str(workdir), "--port", "6899"])
|
|
99
|
+
|
|
100
|
+
assert calls == [("flowent.main:app", {"host": "127.0.0.1", "port": 6899})]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_main_reads_host_from_environment(tmp_path, monkeypatch) -> None:
|
|
104
|
+
workdir = tmp_path / "workspace"
|
|
105
|
+
workdir.mkdir()
|
|
106
|
+
calls: list[tuple[str, dict[str, object]]] = []
|
|
107
|
+
|
|
108
|
+
def fake_run(app: str, **kwargs: object) -> None:
|
|
109
|
+
calls.append((app, kwargs))
|
|
110
|
+
|
|
111
|
+
monkeypatch.setenv("FLOWENT_HOST", "0.0.0.0")
|
|
112
|
+
monkeypatch.setenv(WORKDIR_ENV_VAR, str(workdir))
|
|
113
|
+
monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(run=fake_run))
|
|
114
|
+
|
|
115
|
+
main(["--workdir", str(workdir), "--port", "6899"])
|
|
116
|
+
|
|
117
|
+
assert calls == [("flowent.main:app", {"host": "0.0.0.0", "port": 6899})]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_main_prefers_host_argument_over_environment(tmp_path, monkeypatch) -> None:
|
|
121
|
+
workdir = tmp_path / "workspace"
|
|
122
|
+
workdir.mkdir()
|
|
123
|
+
calls: list[tuple[str, dict[str, object]]] = []
|
|
124
|
+
|
|
125
|
+
def fake_run(app: str, **kwargs: object) -> None:
|
|
126
|
+
calls.append((app, kwargs))
|
|
127
|
+
|
|
128
|
+
monkeypatch.setenv("FLOWENT_HOST", "0.0.0.0")
|
|
129
|
+
monkeypatch.setenv(WORKDIR_ENV_VAR, str(workdir))
|
|
130
|
+
monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(run=fake_run))
|
|
131
|
+
|
|
132
|
+
main(["--workdir", str(workdir), "--host", "127.0.0.1", "--port", "6899"])
|
|
133
|
+
|
|
134
|
+
assert calls == [("flowent.main:app", {"host": "127.0.0.1", "port": 6899})]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_main_rejects_missing_workdir(tmp_path, capsys) -> None:
|
|
138
|
+
missing = tmp_path / "missing"
|
|
139
|
+
|
|
140
|
+
with pytest.raises(SystemExit) as error:
|
|
141
|
+
main(["--workdir", str(missing)])
|
|
142
|
+
|
|
143
|
+
assert error.value.code == 2
|
|
144
|
+
assert f"Workdir does not exist: {missing}" in capsys.readouterr().err
|