flowent 0.1.2 → 0.1.3
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__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.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 +1 -0
- package/backend/src/flowent/main.py +297 -55
- package/backend/src/flowent/permissions.py +259 -0
- package/backend/src/flowent/sandbox.py +14 -4
- package/backend/src/flowent/static/assets/index-DjF2KBwE.js +81 -0
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +64 -0
- package/backend/src/flowent/tools.py +24 -1
- 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_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_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_permissions.py +443 -0
- package/backend/tests/test_workspace_chat.py +127 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
- package/dist/frontend/assets/index-P-bBpJG8.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
- package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
- package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
- package/dist/frontend/assets/index-C89n9qe2.css +0 -2
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi.testclient import TestClient
|
|
5
|
+
|
|
6
|
+
from flowent.main import create_app
|
|
7
|
+
from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
|
|
8
|
+
from flowent.sandbox import CommandResult, SandboxRunner
|
|
9
|
+
from flowent.storage import StateStore
|
|
10
|
+
from flowent.tools import ToolContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_app_state_persists_writable_paths_across_app_instances(
|
|
14
|
+
tmp_path, monkeypatch
|
|
15
|
+
) -> None:
|
|
16
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
17
|
+
monkeypatch.chdir(tmp_path)
|
|
18
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
19
|
+
|
|
20
|
+
response = client.post(
|
|
21
|
+
"/api/permissions/writable-paths",
|
|
22
|
+
json={"path": "cache"},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
assert response.status_code == 200
|
|
26
|
+
restarted_client = TestClient(create_app(serve_frontend=False))
|
|
27
|
+
state_response = restarted_client.get("/api/state")
|
|
28
|
+
|
|
29
|
+
assert state_response.status_code == 200
|
|
30
|
+
assert state_response.json()["writable_paths"][0]["path"] == str(tmp_path / "cache")
|
|
31
|
+
assert isinstance(state_response.json()["writable_paths"][0]["created_at"], int)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_delete_writable_path_removes_permission(tmp_path, monkeypatch) -> None:
|
|
35
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
36
|
+
monkeypatch.chdir(tmp_path)
|
|
37
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
38
|
+
client.post("/api/permissions/writable-paths", json={"path": "cache"})
|
|
39
|
+
|
|
40
|
+
response = client.request(
|
|
41
|
+
"DELETE",
|
|
42
|
+
"/api/permissions/writable-paths",
|
|
43
|
+
json={"path": str(tmp_path / "cache")},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
assert response.status_code == 200
|
|
47
|
+
assert response.json() == {"writable_paths": []}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_writable_paths_are_saved_as_normalized_absolute_paths(
|
|
51
|
+
tmp_path, monkeypatch
|
|
52
|
+
) -> None:
|
|
53
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
54
|
+
monkeypatch.chdir(tmp_path)
|
|
55
|
+
store = StateStore()
|
|
56
|
+
|
|
57
|
+
store.save_writable_path(Path("cache") / ".." / "cache")
|
|
58
|
+
store.save_writable_path(tmp_path / "cache")
|
|
59
|
+
|
|
60
|
+
writable_paths = store.read_writable_paths()
|
|
61
|
+
|
|
62
|
+
assert [path.path for path in writable_paths] == [str(tmp_path / "cache")]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.anyio
|
|
66
|
+
async def test_allow_once_runs_tool_with_declared_write_path(
|
|
67
|
+
tmp_path, monkeypatch
|
|
68
|
+
) -> None:
|
|
69
|
+
cache_dir = tmp_path / "cache"
|
|
70
|
+
calls: list[list[Path]] = []
|
|
71
|
+
|
|
72
|
+
async def fake_run_async(self, command, **kwargs):
|
|
73
|
+
calls.append(self.writable_roots)
|
|
74
|
+
return CommandResult(
|
|
75
|
+
command=" ".join(command),
|
|
76
|
+
exit_code=0,
|
|
77
|
+
stderr="",
|
|
78
|
+
stdout="created",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
82
|
+
assert path == cache_dir
|
|
83
|
+
assert "shell command" in reason
|
|
84
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
85
|
+
|
|
86
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
87
|
+
|
|
88
|
+
result = await run_tool_with_path_permissions(
|
|
89
|
+
"shell_command",
|
|
90
|
+
{
|
|
91
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
92
|
+
"command": f"echo created > {cache_dir / 'file.txt'}",
|
|
93
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
94
|
+
},
|
|
95
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
96
|
+
request_writable_path=approve,
|
|
97
|
+
writable_paths=[],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert result.ok
|
|
101
|
+
assert result.content == "created"
|
|
102
|
+
assert len(calls) == 1
|
|
103
|
+
assert cache_dir in calls[0]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.anyio
|
|
107
|
+
async def test_always_allow_runs_tool_and_persists_declared_path(
|
|
108
|
+
tmp_path, monkeypatch
|
|
109
|
+
) -> None:
|
|
110
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
111
|
+
store = StateStore()
|
|
112
|
+
cache_dir = tmp_path / "cache"
|
|
113
|
+
|
|
114
|
+
async def fake_run_async(self, command, **kwargs):
|
|
115
|
+
assert cache_dir in self.writable_roots
|
|
116
|
+
return CommandResult(
|
|
117
|
+
command=" ".join(command),
|
|
118
|
+
exit_code=0,
|
|
119
|
+
stderr="",
|
|
120
|
+
stdout="created",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
124
|
+
saved = store.save_writable_path(path)
|
|
125
|
+
return WritablePathDecision(decision="always_allow", path=Path(saved.path))
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
128
|
+
|
|
129
|
+
result = await run_tool_with_path_permissions(
|
|
130
|
+
"shell_command",
|
|
131
|
+
{
|
|
132
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
133
|
+
"command": f"mkdir -p {cache_dir}",
|
|
134
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
135
|
+
},
|
|
136
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
137
|
+
request_writable_path=approve,
|
|
138
|
+
writable_paths=[],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
assert result.ok
|
|
142
|
+
assert [path.path for path in store.read_writable_paths()] == [str(cache_dir)]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.anyio
|
|
146
|
+
async def test_deny_returns_failed_tool_result_before_running_command(
|
|
147
|
+
tmp_path, monkeypatch
|
|
148
|
+
) -> None:
|
|
149
|
+
cache_dir = tmp_path / "cache"
|
|
150
|
+
calls = 0
|
|
151
|
+
|
|
152
|
+
async def fake_run_async(self, command, **kwargs):
|
|
153
|
+
nonlocal calls
|
|
154
|
+
calls += 1
|
|
155
|
+
return CommandResult(
|
|
156
|
+
command=" ".join(command),
|
|
157
|
+
exit_code=0,
|
|
158
|
+
stderr="",
|
|
159
|
+
stdout="created",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def deny(path: Path, reason: str) -> WritablePathDecision:
|
|
163
|
+
return WritablePathDecision(decision="deny", path=path)
|
|
164
|
+
|
|
165
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
166
|
+
|
|
167
|
+
result = await run_tool_with_path_permissions(
|
|
168
|
+
"shell_command",
|
|
169
|
+
{
|
|
170
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
171
|
+
"command": f"echo created > {cache_dir / 'file.txt'}",
|
|
172
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
173
|
+
},
|
|
174
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
175
|
+
request_writable_path=deny,
|
|
176
|
+
writable_paths=[],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert not result.ok
|
|
180
|
+
assert "Permission denied for" in result.content
|
|
181
|
+
assert calls == 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.anyio
|
|
185
|
+
async def test_existing_writable_path_covers_declared_permission_request(
|
|
186
|
+
tmp_path, monkeypatch
|
|
187
|
+
) -> None:
|
|
188
|
+
cache_dir = tmp_path / "cache"
|
|
189
|
+
requests = 0
|
|
190
|
+
|
|
191
|
+
async def fake_run_async(self, command, **kwargs):
|
|
192
|
+
assert cache_dir in self.writable_roots
|
|
193
|
+
return CommandResult(
|
|
194
|
+
command=" ".join(command),
|
|
195
|
+
exit_code=0,
|
|
196
|
+
stderr="",
|
|
197
|
+
stdout="created",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
201
|
+
nonlocal requests
|
|
202
|
+
requests += 1
|
|
203
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
204
|
+
|
|
205
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
206
|
+
|
|
207
|
+
result = await run_tool_with_path_permissions(
|
|
208
|
+
"shell_command",
|
|
209
|
+
{
|
|
210
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
211
|
+
"command": f"echo created > {cache_dir / 'file.txt'}",
|
|
212
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
213
|
+
},
|
|
214
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
215
|
+
request_writable_path=approve,
|
|
216
|
+
writable_paths=[cache_dir],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
assert result.ok
|
|
220
|
+
assert requests == 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.anyio
|
|
224
|
+
async def test_multiple_declared_write_paths_request_each_missing_path(
|
|
225
|
+
tmp_path, monkeypatch
|
|
226
|
+
) -> None:
|
|
227
|
+
first = tmp_path / "cache"
|
|
228
|
+
second = tmp_path / "downloads"
|
|
229
|
+
requested: list[Path] = []
|
|
230
|
+
|
|
231
|
+
async def fake_run_async(self, command, **kwargs):
|
|
232
|
+
assert first in self.writable_roots
|
|
233
|
+
assert second in self.writable_roots
|
|
234
|
+
return CommandResult(
|
|
235
|
+
command=" ".join(command),
|
|
236
|
+
exit_code=0,
|
|
237
|
+
stderr="",
|
|
238
|
+
stdout="created",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
242
|
+
requested.append(path)
|
|
243
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
244
|
+
|
|
245
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
246
|
+
|
|
247
|
+
result = await run_tool_with_path_permissions(
|
|
248
|
+
"shell_command",
|
|
249
|
+
{
|
|
250
|
+
"additional_permissions": {
|
|
251
|
+
"file_system": {"write": [str(first), str(second)]}
|
|
252
|
+
},
|
|
253
|
+
"command": "touch done",
|
|
254
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
255
|
+
},
|
|
256
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
257
|
+
request_writable_path=approve,
|
|
258
|
+
writable_paths=[],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
assert result.ok
|
|
262
|
+
assert requested == [first, second]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@pytest.mark.anyio
|
|
266
|
+
async def test_command_text_is_not_used_to_guess_permissions(
|
|
267
|
+
tmp_path, monkeypatch
|
|
268
|
+
) -> None:
|
|
269
|
+
outside = tmp_path / "outside"
|
|
270
|
+
requests = 0
|
|
271
|
+
|
|
272
|
+
async def fake_run_async(self, command, **kwargs):
|
|
273
|
+
assert outside not in self.writable_roots
|
|
274
|
+
return CommandResult(
|
|
275
|
+
command=" ".join(command),
|
|
276
|
+
exit_code=1,
|
|
277
|
+
stderr=f"rm: cannot remove '{outside / 'file.txt'}': Read-only file system\n",
|
|
278
|
+
stdout="",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
282
|
+
nonlocal requests
|
|
283
|
+
requests += 1
|
|
284
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
285
|
+
|
|
286
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
287
|
+
|
|
288
|
+
result = await run_tool_with_path_permissions(
|
|
289
|
+
"shell_command",
|
|
290
|
+
{"command": f"rm -f {outside / 'file.txt'}"},
|
|
291
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
292
|
+
request_writable_path=approve,
|
|
293
|
+
writable_paths=[],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
assert not result.ok
|
|
297
|
+
assert requests == 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@pytest.mark.anyio
|
|
301
|
+
async def test_additional_permissions_require_matching_sandbox_permissions(
|
|
302
|
+
tmp_path, monkeypatch
|
|
303
|
+
) -> None:
|
|
304
|
+
cache_dir = tmp_path / "cache"
|
|
305
|
+
calls = 0
|
|
306
|
+
|
|
307
|
+
async def fake_run_async(self, command, **kwargs):
|
|
308
|
+
nonlocal calls
|
|
309
|
+
calls += 1
|
|
310
|
+
return CommandResult(
|
|
311
|
+
command=" ".join(command),
|
|
312
|
+
exit_code=0,
|
|
313
|
+
stderr="",
|
|
314
|
+
stdout="created",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
318
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
319
|
+
|
|
320
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
321
|
+
|
|
322
|
+
result = await run_tool_with_path_permissions(
|
|
323
|
+
"shell_command",
|
|
324
|
+
{
|
|
325
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
326
|
+
"command": f"touch {cache_dir / 'file.txt'}",
|
|
327
|
+
},
|
|
328
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
329
|
+
request_writable_path=approve,
|
|
330
|
+
writable_paths=[],
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
assert not result.ok
|
|
334
|
+
assert "with_additional_permissions" in result.content
|
|
335
|
+
assert calls == 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@pytest.mark.anyio
|
|
339
|
+
async def test_apply_patch_requests_permission_for_outside_workdir_file(
|
|
340
|
+
tmp_path, monkeypatch
|
|
341
|
+
) -> None:
|
|
342
|
+
work_dir = tmp_path / "work"
|
|
343
|
+
work_dir.mkdir()
|
|
344
|
+
outside_dir = tmp_path / "outside"
|
|
345
|
+
outside_dir.mkdir()
|
|
346
|
+
target = outside_dir / "notes.txt"
|
|
347
|
+
target.write_text("alpha\n")
|
|
348
|
+
requested: list[Path] = []
|
|
349
|
+
|
|
350
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
351
|
+
requested.append(path)
|
|
352
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
353
|
+
|
|
354
|
+
patch = f"""*** Begin Patch
|
|
355
|
+
*** Update File: {target}
|
|
356
|
+
@@
|
|
357
|
+
-alpha
|
|
358
|
+
+beta
|
|
359
|
+
*** End Patch
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
result = await run_tool_with_path_permissions(
|
|
363
|
+
"apply_patch",
|
|
364
|
+
{"patch": patch},
|
|
365
|
+
ToolContext(cwd=work_dir),
|
|
366
|
+
request_writable_path=approve,
|
|
367
|
+
writable_paths=[],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
assert result.ok
|
|
371
|
+
assert requested == [outside_dir]
|
|
372
|
+
assert target.read_text() == "beta\n"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@pytest.mark.anyio
|
|
376
|
+
async def test_apply_patch_uses_existing_writable_path_without_request(
|
|
377
|
+
tmp_path, monkeypatch
|
|
378
|
+
) -> None:
|
|
379
|
+
work_dir = tmp_path / "work"
|
|
380
|
+
work_dir.mkdir()
|
|
381
|
+
outside_dir = tmp_path / "outside"
|
|
382
|
+
outside_dir.mkdir()
|
|
383
|
+
target = outside_dir / "notes.txt"
|
|
384
|
+
target.write_text("alpha\n")
|
|
385
|
+
requests = 0
|
|
386
|
+
|
|
387
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
388
|
+
nonlocal requests
|
|
389
|
+
requests += 1
|
|
390
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
391
|
+
|
|
392
|
+
patch = f"""*** Begin Patch
|
|
393
|
+
*** Update File: {target}
|
|
394
|
+
@@
|
|
395
|
+
-alpha
|
|
396
|
+
+beta
|
|
397
|
+
*** End Patch
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
result = await run_tool_with_path_permissions(
|
|
401
|
+
"apply_patch",
|
|
402
|
+
{"patch": patch},
|
|
403
|
+
ToolContext(cwd=work_dir),
|
|
404
|
+
request_writable_path=approve,
|
|
405
|
+
writable_paths=[outside_dir],
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
assert result.ok
|
|
409
|
+
assert requests == 0
|
|
410
|
+
assert target.read_text() == "beta\n"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@pytest.mark.anyio
|
|
414
|
+
async def test_denied_apply_patch_does_not_modify_file(tmp_path) -> None:
|
|
415
|
+
work_dir = tmp_path / "work"
|
|
416
|
+
work_dir.mkdir()
|
|
417
|
+
outside_dir = tmp_path / "outside"
|
|
418
|
+
outside_dir.mkdir()
|
|
419
|
+
target = outside_dir / "notes.txt"
|
|
420
|
+
target.write_text("alpha\n")
|
|
421
|
+
|
|
422
|
+
async def deny(path: Path, reason: str) -> WritablePathDecision:
|
|
423
|
+
return WritablePathDecision(decision="deny", path=path)
|
|
424
|
+
|
|
425
|
+
patch = f"""*** Begin Patch
|
|
426
|
+
*** Update File: {target}
|
|
427
|
+
@@
|
|
428
|
+
-alpha
|
|
429
|
+
+beta
|
|
430
|
+
*** End Patch
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
result = await run_tool_with_path_permissions(
|
|
434
|
+
"apply_patch",
|
|
435
|
+
{"patch": patch},
|
|
436
|
+
ToolContext(cwd=work_dir),
|
|
437
|
+
request_writable_path=deny,
|
|
438
|
+
writable_paths=[],
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
assert not result.ok
|
|
442
|
+
assert "Permission denied for" in result.content
|
|
443
|
+
assert target.read_text() == "alpha\n"
|
|
@@ -872,3 +872,130 @@ def test_workspace_marks_draft_complete_when_stream_finishes(
|
|
|
872
872
|
assert assistant["author"] == "assistant"
|
|
873
873
|
assert assistant["content"] == "Done."
|
|
874
874
|
assert assistant.get("status", "completed") == "completed"
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
@pytest.mark.anyio
|
|
878
|
+
async def test_workspace_run_continues_without_stream_consumer(
|
|
879
|
+
tmp_path, monkeypatch
|
|
880
|
+
) -> None:
|
|
881
|
+
monkeypatch.chdir(tmp_path)
|
|
882
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
883
|
+
first_chunk_sent = asyncio.Event()
|
|
884
|
+
finish_response = asyncio.Event()
|
|
885
|
+
|
|
886
|
+
async def fake_completion(**request: object) -> object:
|
|
887
|
+
async def chunks() -> object:
|
|
888
|
+
yield {"choices": [{"delta": {"content": "Partial "}}]}
|
|
889
|
+
first_chunk_sent.set()
|
|
890
|
+
await asyncio.wait_for(finish_response.wait(), timeout=2)
|
|
891
|
+
yield {"choices": [{"delta": {"content": "answer."}}]}
|
|
892
|
+
|
|
893
|
+
return chunks()
|
|
894
|
+
|
|
895
|
+
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
896
|
+
async with httpx.AsyncClient(
|
|
897
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
898
|
+
) as client:
|
|
899
|
+
await configure_provider_async(client)
|
|
900
|
+
response = await client.post(
|
|
901
|
+
"/api/workspace/runs",
|
|
902
|
+
json={"content": "Keep working."},
|
|
903
|
+
)
|
|
904
|
+
assert response.status_code == 200
|
|
905
|
+
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
906
|
+
finish_response.set()
|
|
907
|
+
|
|
908
|
+
for _ in range(20):
|
|
909
|
+
state = (await client.get("/api/state")).json()
|
|
910
|
+
assistant = state["messages"][-1]
|
|
911
|
+
if (
|
|
912
|
+
assistant["author"] == "assistant"
|
|
913
|
+
and assistant.get("status", "completed") == "completed"
|
|
914
|
+
):
|
|
915
|
+
break
|
|
916
|
+
await asyncio.sleep(0.05)
|
|
917
|
+
else:
|
|
918
|
+
raise AssertionError("Workspace run did not complete.")
|
|
919
|
+
|
|
920
|
+
assert assistant["content"] == "Partial answer."
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
@pytest.mark.anyio
|
|
924
|
+
async def test_workspace_state_exposes_active_run_for_reconnect(
|
|
925
|
+
tmp_path, monkeypatch
|
|
926
|
+
) -> None:
|
|
927
|
+
monkeypatch.chdir(tmp_path)
|
|
928
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
929
|
+
first_chunk_sent = asyncio.Event()
|
|
930
|
+
finish_response = asyncio.Event()
|
|
931
|
+
|
|
932
|
+
async def fake_completion(**request: object) -> object:
|
|
933
|
+
async def chunks() -> object:
|
|
934
|
+
yield {"choices": [{"delta": {"content": "First "}}]}
|
|
935
|
+
first_chunk_sent.set()
|
|
936
|
+
await asyncio.wait_for(finish_response.wait(), timeout=2)
|
|
937
|
+
yield {"choices": [{"delta": {"content": "second."}}]}
|
|
938
|
+
|
|
939
|
+
return chunks()
|
|
940
|
+
|
|
941
|
+
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
942
|
+
async with httpx.AsyncClient(
|
|
943
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
944
|
+
) as client:
|
|
945
|
+
await configure_provider_async(client)
|
|
946
|
+
response = await client.post(
|
|
947
|
+
"/api/workspace/runs",
|
|
948
|
+
json={"content": "Continue if I reconnect."},
|
|
949
|
+
)
|
|
950
|
+
run_id = response.json()["run_id"]
|
|
951
|
+
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
952
|
+
state = (await client.get("/api/state")).json()
|
|
953
|
+
event_index = state["active_run_event_index"]
|
|
954
|
+
finish_response.set()
|
|
955
|
+
stream_response = await client.get(
|
|
956
|
+
f"/api/workspace/runs/{run_id}/stream?after={event_index}"
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
assert state["active_run_id"] == run_id
|
|
960
|
+
assert event_index > 0
|
|
961
|
+
events = stream_events(stream_response.text)
|
|
962
|
+
assert {"event": "delta", "data": '{"content": "First "}'} not in events
|
|
963
|
+
assert {"event": "delta", "data": '{"content": "second."}'} in events
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
@pytest.mark.anyio
|
|
967
|
+
async def test_workspace_clear_removes_running_run_draft(tmp_path, monkeypatch) -> None:
|
|
968
|
+
monkeypatch.chdir(tmp_path)
|
|
969
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
970
|
+
first_chunk_sent = asyncio.Event()
|
|
971
|
+
finish_response = asyncio.Event()
|
|
972
|
+
|
|
973
|
+
async def fake_completion(**request: object) -> object:
|
|
974
|
+
async def chunks() -> object:
|
|
975
|
+
yield {"choices": [{"delta": {"content": "Partial"}}]}
|
|
976
|
+
first_chunk_sent.set()
|
|
977
|
+
await finish_response.wait()
|
|
978
|
+
|
|
979
|
+
return chunks()
|
|
980
|
+
|
|
981
|
+
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
982
|
+
async with httpx.AsyncClient(
|
|
983
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
984
|
+
) as client:
|
|
985
|
+
await configure_provider_async(client)
|
|
986
|
+
response = await client.post(
|
|
987
|
+
"/api/workspace/runs",
|
|
988
|
+
json={"content": "Keep working."},
|
|
989
|
+
)
|
|
990
|
+
assert response.status_code == 200
|
|
991
|
+
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
992
|
+
clear_response = await client.put(
|
|
993
|
+
"/api/workspace/messages",
|
|
994
|
+
json={"messages": []},
|
|
995
|
+
)
|
|
996
|
+
await asyncio.sleep(0)
|
|
997
|
+
state = (await client.get("/api/state")).json()
|
|
998
|
+
|
|
999
|
+
assert clear_response.status_code == 200
|
|
1000
|
+
assert state["messages"] == []
|
|
1001
|
+
assert state["active_run_id"] is None
|