flowent 0.1.2 → 0.1.4

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 (59) 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__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/agent.py +1 -0
  22. package/backend/src/flowent/cli.py +14 -2
  23. package/backend/src/flowent/compact.py +183 -0
  24. package/backend/src/flowent/main.py +405 -88
  25. package/backend/src/flowent/mcp.py +3 -1
  26. package/backend/src/flowent/paths.py +12 -0
  27. package/backend/src/flowent/permissions.py +259 -0
  28. package/backend/src/flowent/sandbox.py +105 -16
  29. package/backend/src/flowent/static/assets/index-BREidonU.css +2 -0
  30. package/backend/src/flowent/static/assets/index-DSniOrhL.js +81 -0
  31. package/backend/src/flowent/static/index.html +2 -2
  32. package/backend/src/flowent/storage.py +218 -1
  33. package/backend/src/flowent/tools.py +24 -1
  34. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/test_agent_tools.py +235 -0
  47. package/backend/tests/test_mcp.py +76 -10
  48. package/backend/tests/test_permissions.py +443 -0
  49. package/backend/tests/test_startup_requirements.py +42 -0
  50. package/backend/tests/test_workspace_chat.py +443 -9
  51. package/backend/uv.lock +1 -1
  52. package/dist/frontend/assets/index-BREidonU.css +2 -0
  53. package/dist/frontend/assets/index-DSniOrhL.js +81 -0
  54. package/dist/frontend/index.html +2 -2
  55. package/package.json +2 -2
  56. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
  57. package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
  58. package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
  59. package/dist/frontend/assets/index-C89n9qe2.css +0 -2
@@ -6,8 +6,8 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Flowent</title>
8
8
  <meta name="description" content="Flowent application" />
9
- <script type="module" crossorigin src="/assets/index-BhHdc2d_.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-C89n9qe2.css">
9
+ <script type="module" crossorigin src="/assets/index-DSniOrhL.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BREidonU.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from pydantic import BaseModel, ConfigDict, Field
6
6
 
7
- from flowent.llm import ProviderFormat, ReasoningEffort
7
+ from flowent.llm import ChatMessage, ProviderFormat, ReasoningEffort
8
8
  from flowent.paths import data_directory
9
9
 
10
10
 
@@ -68,6 +68,22 @@ class StoredSkill(BaseModel):
68
68
  slug: str
69
69
 
70
70
 
71
+ class StoredWritablePath(BaseModel):
72
+ model_config = ConfigDict(extra="forbid")
73
+
74
+ created_at: int = 0
75
+ path: str
76
+
77
+
78
+ class StoredPermissionRequest(BaseModel):
79
+ model_config = ConfigDict(extra="forbid")
80
+
81
+ id: str
82
+ path: str
83
+ reason: str
84
+ tool_call_id: str | None = None
85
+
86
+
71
87
  class StoredProvider(BaseModel):
72
88
  model_config = ConfigDict(extra="forbid")
73
89
 
@@ -112,15 +128,33 @@ class StoredMessage(BaseModel):
112
128
  tools: list[StoredToolItem] = Field(default_factory=list)
113
129
 
114
130
 
131
+ class StoredCompactionCheckpoint(BaseModel):
132
+ model_config = ConfigDict(extra="forbid")
133
+
134
+ created_at: int = 0
135
+ id: str
136
+ method: str
137
+ replacement_history: list[ChatMessage]
138
+ source_message_id: str | None = None
139
+ summary: str
140
+ token_after: int = 0
141
+ token_before: int = 0
142
+ trigger: str
143
+
144
+
115
145
  class StoredState(BaseModel):
116
146
  model_config = ConfigDict(extra="forbid")
117
147
 
148
+ active_run_event_index: int = 0
149
+ active_run_id: str | None = None
118
150
  mcp_servers: list[StoredMcpServer]
119
151
  messages: list[StoredMessage]
120
152
  providers: list[StoredProvider]
153
+ permission_requests: list[StoredPermissionRequest] = Field(default_factory=list)
121
154
  settings: StoredSettings
122
155
  skills: list[StoredSkill]
123
156
  telegram_bot: StoredTelegramBot
157
+ writable_paths: list[StoredWritablePath] = Field(default_factory=list)
124
158
 
125
159
 
126
160
  class StateStore:
@@ -142,6 +176,7 @@ class StateStore:
142
176
  with self.connect() as connection:
143
177
  mcp_servers = self._read_mcp_servers(connection)
144
178
  telegram_bot = self._read_telegram_bot(connection)
179
+ writable_paths = self._read_writable_paths(connection)
145
180
  providers = [
146
181
  StoredProvider(
147
182
  api_key=row["api_key"],
@@ -202,8 +237,42 @@ class StateStore:
202
237
  ),
203
238
  skills=[],
204
239
  telegram_bot=telegram_bot,
240
+ writable_paths=writable_paths,
205
241
  )
206
242
 
243
+ def read_writable_paths(self) -> list[StoredWritablePath]:
244
+ with self.connect() as connection:
245
+ return self._read_writable_paths(connection)
246
+
247
+ def save_writable_path(self, path: Path) -> StoredWritablePath:
248
+ normalized_path = str(path.expanduser().resolve(strict=False))
249
+ with self.connect() as connection:
250
+ connection.execute(
251
+ """
252
+ INSERT INTO writable_paths (path)
253
+ VALUES (?)
254
+ ON CONFLICT(path) DO NOTHING
255
+ """,
256
+ (normalized_path,),
257
+ )
258
+ row = connection.execute(
259
+ """
260
+ SELECT path, created_at
261
+ FROM writable_paths
262
+ WHERE path = ?
263
+ """,
264
+ (normalized_path,),
265
+ ).fetchone()
266
+ return StoredWritablePath(path=row["path"], created_at=row["created_at"])
267
+
268
+ def delete_writable_path(self, path: Path) -> list[StoredWritablePath]:
269
+ normalized_path = str(path.expanduser().resolve(strict=False))
270
+ with self.connect() as connection:
271
+ connection.execute(
272
+ "DELETE FROM writable_paths WHERE path = ?", (normalized_path,)
273
+ )
274
+ return self._read_writable_paths(connection)
275
+
207
276
  def read_skill_enabled(self) -> dict[str, bool]:
208
277
  with self.connect() as connection:
209
278
  return {
@@ -562,12 +631,120 @@ class StateStore:
562
631
  VALUES (1, ?)
563
632
  ON CONFLICT(id) DO UPDATE SET
564
633
  compacted_summary = excluded.compacted_summary,
634
+ active_compaction_id = NULL,
565
635
  updated_at = unixepoch()
566
636
  """,
567
637
  (summary,),
568
638
  )
569
639
  return summary
570
640
 
641
+ def read_active_compaction_checkpoint(
642
+ self,
643
+ ) -> StoredCompactionCheckpoint | None:
644
+ with self.connect() as connection:
645
+ row = connection.execute(
646
+ """
647
+ SELECT
648
+ checkpoint.id,
649
+ checkpoint.trigger,
650
+ checkpoint.method,
651
+ checkpoint.summary,
652
+ checkpoint.replacement_history,
653
+ checkpoint.source_message_id,
654
+ checkpoint.token_before,
655
+ checkpoint.token_after,
656
+ checkpoint.created_at
657
+ FROM workspace_context context
658
+ JOIN compaction_checkpoints checkpoint
659
+ ON checkpoint.id = context.active_compaction_id
660
+ WHERE context.id = 1
661
+ """
662
+ ).fetchone()
663
+ if row is None:
664
+ return None
665
+ return StoredCompactionCheckpoint(
666
+ created_at=row["created_at"],
667
+ id=row["id"],
668
+ method=row["method"],
669
+ replacement_history=[
670
+ ChatMessage.model_validate(message)
671
+ for message in json.loads(row["replacement_history"] or "[]")
672
+ ],
673
+ source_message_id=row["source_message_id"],
674
+ summary=row["summary"],
675
+ token_after=row["token_after"],
676
+ token_before=row["token_before"],
677
+ trigger=row["trigger"],
678
+ )
679
+
680
+ def save_compaction_checkpoint(
681
+ self, checkpoint: StoredCompactionCheckpoint
682
+ ) -> StoredCompactionCheckpoint:
683
+ with self.connect() as connection:
684
+ connection.execute(
685
+ """
686
+ INSERT INTO compaction_checkpoints (
687
+ id,
688
+ trigger,
689
+ method,
690
+ summary,
691
+ replacement_history,
692
+ source_message_id,
693
+ token_before,
694
+ token_after
695
+ )
696
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
697
+ ON CONFLICT(id) DO UPDATE SET
698
+ trigger = excluded.trigger,
699
+ method = excluded.method,
700
+ summary = excluded.summary,
701
+ replacement_history = excluded.replacement_history,
702
+ source_message_id = excluded.source_message_id,
703
+ token_before = excluded.token_before,
704
+ token_after = excluded.token_after
705
+ """,
706
+ (
707
+ checkpoint.id,
708
+ checkpoint.trigger,
709
+ checkpoint.method,
710
+ checkpoint.summary,
711
+ json.dumps(
712
+ [
713
+ message.model_dump()
714
+ for message in checkpoint.replacement_history
715
+ ],
716
+ ensure_ascii=False,
717
+ ),
718
+ checkpoint.source_message_id,
719
+ checkpoint.token_before,
720
+ checkpoint.token_after,
721
+ ),
722
+ )
723
+ connection.execute(
724
+ """
725
+ INSERT INTO workspace_context (
726
+ id,
727
+ compacted_summary,
728
+ active_compaction_id
729
+ )
730
+ VALUES (1, ?, ?)
731
+ ON CONFLICT(id) DO UPDATE SET
732
+ compacted_summary = excluded.compacted_summary,
733
+ active_compaction_id = excluded.active_compaction_id,
734
+ updated_at = unixepoch()
735
+ """,
736
+ (checkpoint.summary, checkpoint.id),
737
+ )
738
+ row = connection.execute(
739
+ """
740
+ SELECT created_at
741
+ FROM compaction_checkpoints
742
+ WHERE id = ?
743
+ """,
744
+ (checkpoint.id,),
745
+ ).fetchone()
746
+ return checkpoint.model_copy(update={"created_at": row["created_at"]})
747
+
571
748
  def _provider_models(
572
749
  self, connection: sqlite3.Connection, provider_id: str
573
750
  ) -> list[str]:
@@ -669,6 +846,20 @@ class StateStore:
669
846
  )
670
847
  return servers
671
848
 
849
+ def _read_writable_paths(
850
+ self, connection: sqlite3.Connection
851
+ ) -> list[StoredWritablePath]:
852
+ return [
853
+ StoredWritablePath(created_at=row["created_at"], path=row["path"])
854
+ for row in connection.execute(
855
+ """
856
+ SELECT path, created_at
857
+ FROM writable_paths
858
+ ORDER BY path
859
+ """
860
+ )
861
+ ]
862
+
672
863
  def _migrate(self, connection: sqlite3.Connection) -> None:
673
864
  connection.executescript(
674
865
  """
@@ -748,15 +939,33 @@ class StateStore:
748
939
  CREATE TABLE IF NOT EXISTS workspace_context (
749
940
  id INTEGER PRIMARY KEY CHECK (id = 1),
750
941
  compacted_summary TEXT NOT NULL DEFAULT '',
942
+ active_compaction_id TEXT,
751
943
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
752
944
  );
753
945
 
946
+ CREATE TABLE IF NOT EXISTS compaction_checkpoints (
947
+ id TEXT PRIMARY KEY,
948
+ trigger TEXT NOT NULL,
949
+ method TEXT NOT NULL,
950
+ summary TEXT NOT NULL,
951
+ replacement_history TEXT NOT NULL DEFAULT '[]',
952
+ source_message_id TEXT,
953
+ token_before INTEGER NOT NULL DEFAULT 0,
954
+ token_after INTEGER NOT NULL DEFAULT 0,
955
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
956
+ );
957
+
754
958
  CREATE TABLE IF NOT EXISTS skill_settings (
755
959
  id TEXT PRIMARY KEY,
756
960
  enabled INTEGER NOT NULL DEFAULT 1,
757
961
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
758
962
  );
759
963
 
964
+ CREATE TABLE IF NOT EXISTS writable_paths (
965
+ path TEXT PRIMARY KEY,
966
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
967
+ );
968
+
760
969
  CREATE TABLE IF NOT EXISTS schema_migrations (
761
970
  version INTEGER PRIMARY KEY
762
971
  );
@@ -797,3 +1006,11 @@ class StateStore:
797
1006
  "ALTER TABLE settings "
798
1007
  "ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'default'"
799
1008
  )
1009
+ workspace_context_columns = {
1010
+ row["name"]
1011
+ for row in connection.execute("PRAGMA table_info(workspace_context)")
1012
+ }
1013
+ if "active_compaction_id" not in workspace_context_columns:
1014
+ connection.execute(
1015
+ "ALTER TABLE workspace_context ADD COLUMN active_compaction_id TEXT"
1016
+ )
@@ -98,12 +98,35 @@ def tool_specs() -> list[dict[str, object]]:
98
98
  "type": "function",
99
99
  "function": {
100
100
  "name": "shell_command",
101
- "description": "Run a shell command.",
101
+ "description": (
102
+ "Run a shell command. If the command needs to write outside the "
103
+ "current workspace, set sandbox_permissions to "
104
+ "with_additional_permissions and list each needed path in "
105
+ "additional_permissions.file_system.write."
106
+ ),
102
107
  "parameters": {
103
108
  "type": "object",
104
109
  "properties": {
105
110
  "command": {"type": "string"},
106
111
  "timeout_seconds": {"type": "integer", "minimum": 1},
112
+ "sandbox_permissions": {
113
+ "type": "string",
114
+ "enum": ["with_additional_permissions"],
115
+ },
116
+ "additional_permissions": {
117
+ "type": "object",
118
+ "properties": {
119
+ "file_system": {
120
+ "type": "object",
121
+ "properties": {
122
+ "write": {
123
+ "type": "array",
124
+ "items": {"type": "string"},
125
+ }
126
+ },
127
+ }
128
+ },
129
+ },
107
130
  },
108
131
  "required": ["command"],
109
132
  },
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import json
3
+ import subprocess
3
4
  import time
4
5
  from pathlib import Path
5
6
 
@@ -226,6 +227,240 @@ def test_shell_command_has_network_by_default(tmp_path) -> None:
226
227
  assert "network-ready" in result.content
227
228
 
228
229
 
230
+ def test_sandbox_command_keeps_proc_mount_when_preflight_succeeds(
231
+ tmp_path, monkeypatch
232
+ ) -> None:
233
+ runner = SandboxRunner(cwd=tmp_path)
234
+ monkeypatch.setattr("flowent.sandbox.sandbox_supports_proc_mount", lambda: True)
235
+
236
+ command = runner.build_command(["/bin/true"])
237
+
238
+ assert command.args[command.args.index("--proc") + 1] == "/proc"
239
+
240
+
241
+ def test_sandbox_command_omits_proc_mount_when_preflight_reports_permission_error(
242
+ tmp_path, monkeypatch
243
+ ) -> None:
244
+ runner = SandboxRunner(cwd=tmp_path)
245
+ monkeypatch.setattr("flowent.sandbox.sandbox_supports_proc_mount", lambda: False)
246
+
247
+ command = runner.build_command(["/bin/true"])
248
+
249
+ assert "--proc" not in command.args
250
+
251
+
252
+ def test_sandbox_proc_preflight_does_not_hide_non_proc_errors(
253
+ tmp_path, monkeypatch
254
+ ) -> None:
255
+ bwrap = tmp_path / "bwrap"
256
+ bwrap.write_text("#!/bin/sh\necho 'bwrap: unrelated startup failure' >&2\nexit 1\n")
257
+ bwrap.chmod(0o700)
258
+ monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
259
+
260
+ assert SandboxRunner(cwd=tmp_path).build_command(["/bin/true"]).args[0:7] == [
261
+ str(bwrap),
262
+ "--ro-bind",
263
+ "/",
264
+ "/",
265
+ "--dev",
266
+ "/dev",
267
+ "--proc",
268
+ ]
269
+
270
+
271
+ def test_shell_command_runs_without_proc_mount_after_preflight_fallback(
272
+ tmp_path, monkeypatch
273
+ ) -> None:
274
+ bwrap = tmp_path / "bwrap"
275
+ bwrap.write_text(
276
+ "#!/bin/sh\n"
277
+ 'for arg in "$@"; do\n'
278
+ ' if [ "$arg" = --proc ]; then\n'
279
+ ' echo "bwrap: Can\'t mount proc on /newroot/proc: Operation not permitted" >&2\n'
280
+ " exit 1\n"
281
+ " fi\n"
282
+ "done\n"
283
+ 'while [ "$#" -gt 0 ]; do\n'
284
+ ' if [ "$1" = -- ]; then\n'
285
+ " shift\n"
286
+ ' exec "$@"\n'
287
+ " fi\n"
288
+ " shift\n"
289
+ "done\n"
290
+ )
291
+ bwrap.chmod(0o700)
292
+ monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
293
+
294
+ result = SandboxRunner(cwd=tmp_path).run(["/bin/sh", "-c", "printf ok"])
295
+
296
+ assert result.exit_code == 0
297
+ assert result.stdout == "ok"
298
+
299
+
300
+ def test_apply_patch_runs_without_proc_mount_after_preflight_fallback(
301
+ tmp_path, monkeypatch
302
+ ) -> None:
303
+ bwrap = tmp_path / "bwrap"
304
+ bwrap.write_text(
305
+ "#!/bin/sh\n"
306
+ 'for arg in "$@"; do\n'
307
+ ' if [ "$arg" = --proc ]; then\n'
308
+ ' echo "bwrap: Can\'t mount proc on /newroot/proc: Operation not permitted" >&2\n'
309
+ " exit 1\n"
310
+ " fi\n"
311
+ "done\n"
312
+ 'while [ "$#" -gt 0 ]; do\n'
313
+ ' if [ "$1" = -- ]; then\n'
314
+ " shift\n"
315
+ ' exec "$@"\n'
316
+ " fi\n"
317
+ " shift\n"
318
+ "done\n"
319
+ )
320
+ bwrap.chmod(0o700)
321
+ monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
322
+ target = tmp_path / "notes.txt"
323
+ target.write_text("alpha\n")
324
+ patch = """*** Begin Patch
325
+ *** Update File: notes.txt
326
+ @@
327
+ -alpha
328
+ +beta
329
+ *** End Patch
330
+ """
331
+
332
+ result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
333
+
334
+ assert result.ok
335
+ assert target.read_text() == "beta\n"
336
+
337
+
338
+ def test_shell_command_environment_omits_development_variables(
339
+ tmp_path, monkeypatch
340
+ ) -> None:
341
+ monkeypatch.setenv("NODE_ENV", "production")
342
+ monkeypatch.setenv("VIRTUAL_ENV", "/tmp/flowent-venv")
343
+ monkeypatch.setenv("PYTHONPATH", "/tmp/flowent-pythonpath")
344
+ runner = SandboxRunner(cwd=tmp_path)
345
+ monkeypatch.setattr(
346
+ runner,
347
+ "build_command",
348
+ lambda command: SandboxCommand(command, seccomp_available=False),
349
+ )
350
+
351
+ result = runner.run(
352
+ [
353
+ "/bin/sh",
354
+ "-c",
355
+ 'printf \'%s|%s|%s\' "${NODE_ENV-unset}" "${VIRTUAL_ENV-unset}" "${PYTHONPATH-unset}"',
356
+ ]
357
+ )
358
+
359
+ assert result.exit_code == 0
360
+ assert result.stdout == "unset|unset|unset"
361
+
362
+
363
+ def test_shell_command_environment_omits_sensitive_variables(
364
+ tmp_path, monkeypatch
365
+ ) -> None:
366
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-local")
367
+ monkeypatch.setenv("SECRET_TOKEN", "secret")
368
+ monkeypatch.setenv("NPM_TOKEN", "npm")
369
+ runner = SandboxRunner(cwd=tmp_path)
370
+ monkeypatch.setattr(
371
+ runner,
372
+ "build_command",
373
+ lambda command: SandboxCommand(command, seccomp_available=False),
374
+ )
375
+
376
+ result = runner.run(
377
+ [
378
+ "/bin/sh",
379
+ "-c",
380
+ 'printf \'%s|%s|%s\' "${OPENAI_API_KEY-unset}" "${SECRET_TOKEN-unset}" "${NPM_TOKEN-unset}"',
381
+ ]
382
+ )
383
+
384
+ assert result.exit_code == 0
385
+ assert result.stdout == "unset|unset|unset"
386
+
387
+
388
+ def test_shell_command_environment_keeps_core_variables(tmp_path, monkeypatch) -> None:
389
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
390
+ monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin:/bin")
391
+ monkeypatch.setenv("SHELL", "/bin/sh")
392
+ monkeypatch.setenv("USER", "flowent")
393
+ runner = SandboxRunner(cwd=tmp_path)
394
+ monkeypatch.setattr(
395
+ runner,
396
+ "build_command",
397
+ lambda command: SandboxCommand(command, seccomp_available=False),
398
+ )
399
+
400
+ result = runner.run(
401
+ [
402
+ "/bin/sh",
403
+ "-c",
404
+ 'printf \'%s|%s|%s|%s\' "$HOME" "$PATH" "$SHELL" "$USER"',
405
+ ]
406
+ )
407
+
408
+ assert result.exit_code == 0
409
+ assert (
410
+ result.stdout
411
+ == f"{tmp_path / 'home'}|/usr/local/bin:/usr/bin:/bin|/bin/sh|flowent"
412
+ )
413
+
414
+
415
+ def test_shell_command_environment_uses_default_path_when_missing(
416
+ tmp_path, monkeypatch
417
+ ) -> None:
418
+ monkeypatch.delenv("PATH", raising=False)
419
+ runner = SandboxRunner(cwd=tmp_path)
420
+ captured_env: dict[str, str] = {}
421
+
422
+ def fake_run(*args, **kwargs):
423
+ captured_env.update(kwargs["env"])
424
+ return subprocess.CompletedProcess(
425
+ args=args[0], returncode=0, stdout="", stderr=""
426
+ )
427
+
428
+ monkeypatch.setattr(
429
+ runner,
430
+ "build_command",
431
+ lambda command: SandboxCommand(command, seccomp_available=False),
432
+ )
433
+ monkeypatch.setattr("subprocess.run", fake_run)
434
+
435
+ result = runner.run(["/bin/sh", "-c", "true"])
436
+
437
+ assert result.exit_code == 0
438
+ assert (
439
+ captured_env["PATH"]
440
+ == "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
441
+ )
442
+
443
+
444
+ def test_shell_command_environment_accepts_explicit_overrides(
445
+ tmp_path, monkeypatch
446
+ ) -> None:
447
+ monkeypatch.delenv("FLOWENT_TOOL_VAR", raising=False)
448
+ runner = SandboxRunner(cwd=tmp_path)
449
+ monkeypatch.setattr(
450
+ runner,
451
+ "build_command",
452
+ lambda command: SandboxCommand(command, seccomp_available=False),
453
+ )
454
+
455
+ result = runner.run(
456
+ ["/bin/sh", "-c", "printf '%s' \"$FLOWENT_TOOL_VAR\""],
457
+ env={"FLOWENT_TOOL_VAR": "explicit"},
458
+ )
459
+
460
+ assert result.exit_code == 0
461
+ assert result.stdout == "explicit"
462
+
463
+
229
464
  @pytest.mark.anyio
230
465
  async def test_async_shell_command_does_not_block_other_tasks(
231
466
  tmp_path, monkeypatch