windcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, replace
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from windcode.config import AppConfig, PermissionMode
10
+ from windcode.context import TokenEstimator
11
+ from windcode.domain.subagents import SubagentRecord, SubagentTaskKind
12
+ from windcode.domain.tools import ToolContext
13
+ from windcode.extensions import ExtensionSnapshot
14
+ from windcode.extensions.events import extension_event
15
+ from windcode.extensions.skills.loader import SkillLoader
16
+ from windcode.extensions.skills.tools import (
17
+ SkillActivationResult,
18
+ SkillCatalog,
19
+ SkillRuntime,
20
+ register_skill_tools,
21
+ )
22
+ from windcode.instructions import load_instructions
23
+ from windcode.observability import TraceStore
24
+ from windcode.policy import ApprovalChoice, PolicyDecision, PolicyEngine, PolicyRequest
25
+ from windcode.providers import ModelTarget
26
+ from windcode.runtime.control import BudgetExceeded, RunBudgets, RunControl
27
+ from windcode.runtime.event_bus import EventBus
28
+ from windcode.runtime.loop import AgentBlocked, AgentLoop
29
+ from windcode.runtime.prompts import build_system_prompt
30
+ from windcode.runtime.scheduler import ScheduledCall, ScheduledResult, ToolScheduler
31
+ from windcode.runtime.subagents.approvals import ApprovalRouter
32
+ from windcode.runtime.subagents.budgets import AggregateBudget, AggregateBudgetExceeded
33
+ from windcode.runtime.subagents.roles import ROLE_POLICIES, resolve_role_tools
34
+ from windcode.sandbox import BubblewrapSandbox, detect_bubblewrap
35
+ from windcode.sessions import ArtifactStore, SessionStore
36
+ from windcode.tools import ToolRegistry
37
+ from windcode.tools.shell import ShellTool
38
+
39
+
40
+ def _git_common_directory(workspace: Path) -> Path | None:
41
+ marker = workspace / ".git"
42
+ if not marker.is_file():
43
+ return marker.resolve() if marker.is_dir() else None
44
+ content = marker.read_text(encoding="utf-8").strip()
45
+ if not content.startswith("gitdir: "):
46
+ return None
47
+ git_directory = Path(content.removeprefix("gitdir: "))
48
+ if not git_directory.is_absolute():
49
+ git_directory = workspace / git_directory
50
+ git_directory = git_directory.resolve()
51
+ common_marker = git_directory / "commondir"
52
+ if not common_marker.is_file():
53
+ return git_directory
54
+ common = Path(common_marker.read_text(encoding="utf-8").strip())
55
+ return (git_directory / common).resolve()
56
+
57
+
58
+ class AggregateRunControl(RunControl):
59
+ def __init__(self, budgets: RunBudgets, aggregate: AggregateBudget) -> None:
60
+ super().__init__(budgets)
61
+ self.aggregate = aggregate
62
+
63
+ def check(self) -> None:
64
+ super().check()
65
+ try:
66
+ self.aggregate.check_runtime_nowait()
67
+ except AggregateBudgetExceeded as exc:
68
+ raise BudgetExceeded(f"aggregate_{exc.budget}") from exc
69
+
70
+ def start_model_step(self) -> int:
71
+ try:
72
+ self.aggregate.consume_model_step_nowait()
73
+ except AggregateBudgetExceeded as exc:
74
+ raise BudgetExceeded(f"aggregate_{exc.budget}") from exc
75
+ return super().start_model_step()
76
+
77
+ def reserve_tool_calls(self, count: int) -> None:
78
+ try:
79
+ self.aggregate.consume_tool_calls_nowait(count)
80
+ except AggregateBudgetExceeded as exc:
81
+ raise BudgetExceeded(f"aggregate_{exc.budget}") from exc
82
+ super().reserve_tool_calls(count)
83
+
84
+
85
+ class ChildToolScheduler(ToolScheduler):
86
+ async def execute(
87
+ self,
88
+ calls: tuple[ScheduledCall, ...],
89
+ context: ToolContext,
90
+ ) -> tuple[ScheduledResult, ...]:
91
+ if any(call.tool_name == "ask_user" for call in calls):
92
+ raise AgentBlocked("subagents cannot ask the user directly; clarification is required")
93
+ return await super().execute(calls, context)
94
+
95
+
96
+ class ChildAgentLoop(AgentLoop):
97
+ def __init__(
98
+ self,
99
+ *,
100
+ record: SubagentRecord,
101
+ approval_router: ApprovalRouter,
102
+ **kwargs: Any,
103
+ ) -> None:
104
+ self.subagent_record = record
105
+ self.approval_router = approval_router
106
+ super().__init__(**kwargs)
107
+
108
+ async def _approval_handler(
109
+ self,
110
+ request: PolicyRequest,
111
+ decision: PolicyDecision,
112
+ ) -> ApprovalChoice:
113
+ return await self.approval_router.request(
114
+ self.subagent_record.subagent_id,
115
+ self.subagent_record.spec.role,
116
+ request,
117
+ decision,
118
+ )
119
+
120
+ async def _request_user(self, payload: object) -> object:
121
+ del payload
122
+ raise AgentBlocked("subagents cannot ask the user directly; clarification is required")
123
+
124
+
125
+ @dataclass(slots=True)
126
+ class ChildRuntime:
127
+ record: SubagentRecord
128
+ control: RunControl
129
+ event_bus: EventBus
130
+ loop: AgentLoop
131
+ workspace: Path
132
+ prompt: str
133
+
134
+
135
+ def build_child_prompt(record: SubagentRecord) -> str:
136
+ spec = record.spec
137
+ verification = "\n".join(f"- {item}" for item in spec.verification)
138
+ return (
139
+ f"Task: {spec.task_name}\n"
140
+ f"Goal: {spec.goal}\n\n"
141
+ f"Context:\n{spec.context}\n\n"
142
+ f"Expected output:\n{spec.expected_output}\n\n"
143
+ f"Verification requirements:\n{verification}\n\n"
144
+ "Complete only this task. Do not delegate and do not ask the user questions."
145
+ )
146
+
147
+
148
+ class ChildRuntimeFactory:
149
+ def __init__(
150
+ self,
151
+ *,
152
+ config: AppConfig,
153
+ state_root: Path,
154
+ parent_tools: ToolRegistry,
155
+ model_chain: Callable[[str | None], tuple[ModelTarget, ...]],
156
+ extension_snapshot: ExtensionSnapshot | None = None,
157
+ ) -> None:
158
+ self.config = config
159
+ self.state_root = state_root
160
+ self.parent_tools = parent_tools
161
+ self.model_chain = model_chain
162
+ self.extension_snapshot = extension_snapshot or ExtensionSnapshot(0, "empty")
163
+
164
+ def create(
165
+ self,
166
+ record: SubagentRecord,
167
+ *,
168
+ workspace: Path,
169
+ parent_permission: PermissionMode,
170
+ aggregate_budget: AggregateBudget,
171
+ approval_router: ApprovalRouter,
172
+ ) -> ChildRuntime:
173
+ spec = record.spec
174
+ policy = ROLE_POLICIES[spec.role]
175
+ names = resolve_role_tools(
176
+ spec.role,
177
+ spec.kind,
178
+ frozenset(self.parent_tools.names()),
179
+ spec.allowed_tools,
180
+ )
181
+ registry = ToolRegistry()
182
+ for name in self.parent_tools.names():
183
+ if name in names and name != "ask_user" and not name.endswith("_subagent"):
184
+ registry.register(self.parent_tools.get(name))
185
+
186
+ sandbox_status = detect_bubblewrap()
187
+ git_common = (
188
+ _git_common_directory(workspace) if spec.kind is SubagentTaskKind.WRITE else None
189
+ )
190
+ sandbox = (
191
+ BubblewrapSandbox(
192
+ workspace,
193
+ sandbox_status,
194
+ read_only_workspace=spec.kind is SubagentTaskKind.READ,
195
+ writable_paths=() if git_common is None else (git_common,),
196
+ )
197
+ if self.config.sandbox.enabled and sandbox_status.available
198
+ else None
199
+ )
200
+ if "shell" in registry.names():
201
+ registry.register(
202
+ ShellTool(
203
+ sandbox=sandbox,
204
+ default_timeout=self.config.budgets.shell_timeout_seconds,
205
+ ),
206
+ replace=True,
207
+ )
208
+ effective_permission = parent_permission
209
+ if spec.kind is SubagentTaskKind.READ and sandbox is None:
210
+ effective_permission = PermissionMode.PLAN
211
+
212
+ child_session_id = record.child_session_id or uuid4().hex
213
+ child_record = replace(record, child_session_id=child_session_id)
214
+ session = SessionStore.create(self.state_root / "sessions", child_session_id)
215
+ child_run_id = uuid4().hex
216
+ event_bus = EventBus(
217
+ session,
218
+ TraceStore(
219
+ child_run_id,
220
+ root=self.state_root / "traces",
221
+ enabled=self.config.trace.enabled,
222
+ include_tool_arguments=self.config.trace.include_tool_arguments,
223
+ include_transient_events=self.config.trace.include_transient_events,
224
+ retention_days=self.config.trace.retention_days,
225
+ max_total_mb=self.config.trace.max_total_mb,
226
+ ),
227
+ )
228
+ skill_runtime = SkillRuntime(
229
+ SkillCatalog(
230
+ self.extension_snapshot,
231
+ SkillLoader(max_content_bytes=self.config.extensions.max_content_bytes),
232
+ )
233
+ )
234
+
235
+ async def activate_skill(selector: str) -> SkillActivationResult:
236
+ result = skill_runtime.activate(selector)
237
+ await event_bus.publish(
238
+ extension_event(
239
+ event_id=uuid4().hex,
240
+ session_id=child_session_id,
241
+ run_id=child_run_id,
242
+ turn=0,
243
+ action="skill_loaded",
244
+ snapshot_generation=self.extension_snapshot.generation,
245
+ extension_id=result.name,
246
+ source_id=result.source_id,
247
+ status="loaded" if result.loaded else "already_loaded",
248
+ ),
249
+ durable=True,
250
+ )
251
+ return result
252
+
253
+ if {"search_skills", "load_skill"} <= names:
254
+ register_skill_tools(registry, skill_runtime, activate_skill, replace=True)
255
+ scheduler = ChildToolScheduler(
256
+ registry,
257
+ PolicyEngine(
258
+ effective_permission,
259
+ sandbox_enabled=self.config.sandbox.enabled,
260
+ sandbox_available=sandbox_status.available,
261
+ ),
262
+ )
263
+ budgets = RunBudgets(
264
+ max_model_steps=self.config.subagents.max_model_steps,
265
+ max_tool_calls=self.config.subagents.max_tool_calls,
266
+ max_runtime_seconds=self.config.subagents.max_runtime_seconds,
267
+ )
268
+ control = AggregateRunControl(budgets, aggregate_budget)
269
+ instructions = load_instructions(workspace, workspace_root=workspace)
270
+ system_prompt = build_system_prompt(
271
+ workspace=workspace,
272
+ permission_mode=effective_permission,
273
+ instructions=instructions,
274
+ tools=registry,
275
+ is_subagent=True,
276
+ skills=(skill_runtime.search() if "load_skill" in registry.names() else ()),
277
+ mcp_direct_servers=tuple(
278
+ name.split("__", 1)[0] for name in registry.names() if "__" in name
279
+ ),
280
+ )
281
+ system_prompt += (
282
+ f"\n\n## Temporary subagent role\n{policy.system_instructions}\n"
283
+ "You are a temporary child agent. You cannot delegate or directly ask the user."
284
+ )
285
+ loop = ChildAgentLoop(
286
+ record=child_record,
287
+ approval_router=approval_router,
288
+ session_id=child_session_id,
289
+ run_id=child_run_id,
290
+ model_chain=self.model_chain(spec.model),
291
+ scheduler=scheduler,
292
+ control=control,
293
+ event_bus=event_bus,
294
+ system_prompt=system_prompt,
295
+ token_estimator=TokenEstimator(
296
+ self.config.context.window_tokens,
297
+ compaction_threshold=self.config.context.compaction_threshold,
298
+ ),
299
+ artifact_store=ArtifactStore(session.session_dir),
300
+ preserve_recent_turns=self.config.context.preserve_recent_turns,
301
+ max_tool_result_chars=self.config.context.max_tool_result_chars,
302
+ sourced_context_provider=skill_runtime.drain_context,
303
+ )
304
+ return ChildRuntime(
305
+ child_record,
306
+ control,
307
+ event_bus,
308
+ loop,
309
+ workspace,
310
+ build_child_prompt(child_record),
311
+ )
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from windcode.domain.subagents import SubagentRole, SubagentTaskKind
6
+
7
+ _READ_TOOLS = frozenset({"read_file", "glob", "grep", "shell", "search_skills", "load_skill"})
8
+ _WRITE_TOOLS = frozenset(
9
+ {
10
+ "read_file",
11
+ "write_file",
12
+ "edit_file",
13
+ "apply_patch",
14
+ "glob",
15
+ "grep",
16
+ "shell",
17
+ "search_skills",
18
+ "load_skill",
19
+ }
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RolePolicy:
25
+ role: SubagentRole
26
+ default_tools: frozenset[str]
27
+ allowed_kinds: frozenset[SubagentTaskKind]
28
+ system_instructions: str
29
+
30
+
31
+ ROLE_POLICIES: dict[SubagentRole, RolePolicy] = {
32
+ SubagentRole.RESEARCHER: RolePolicy(
33
+ role=SubagentRole.RESEARCHER,
34
+ default_tools=_READ_TOOLS,
35
+ allowed_kinds=frozenset({SubagentTaskKind.READ}),
36
+ system_instructions="Explore the workspace and return evidence without modifying files.",
37
+ ),
38
+ SubagentRole.WORKER: RolePolicy(
39
+ role=SubagentRole.WORKER,
40
+ default_tools=_WRITE_TOOLS,
41
+ allowed_kinds=frozenset({SubagentTaskKind.READ, SubagentTaskKind.WRITE}),
42
+ system_instructions="Complete the assigned task and run the requested verification.",
43
+ ),
44
+ SubagentRole.VERIFIER: RolePolicy(
45
+ role=SubagentRole.VERIFIER,
46
+ default_tools=_READ_TOOLS,
47
+ allowed_kinds=frozenset({SubagentTaskKind.READ}),
48
+ system_instructions="Independently verify the requested behavior without modifying files.",
49
+ ),
50
+ }
51
+
52
+
53
+ def resolve_role_tools(
54
+ role: SubagentRole,
55
+ kind: SubagentTaskKind,
56
+ parent_tools: frozenset[str],
57
+ requested_tools: frozenset[str] | None = None,
58
+ ) -> frozenset[str]:
59
+ policy = ROLE_POLICIES[role]
60
+ if kind not in policy.allowed_kinds:
61
+ raise ValueError(f"role {role.value} does not allow {kind.value} tasks")
62
+ network_tools = frozenset(
63
+ name
64
+ for name in parent_tools
65
+ if "__" in name
66
+ or name in {"list_mcp_servers", "search_mcp_tools", "read_mcp_resource", "get_mcp_prompt"}
67
+ )
68
+ allowed_tools = policy.default_tools | network_tools
69
+ if requested_tools is not None:
70
+ unknown = requested_tools - allowed_tools
71
+ if unknown:
72
+ raise ValueError(f"requested tools exceed role policy: {', '.join(sorted(unknown))}")
73
+ selected = allowed_tools if requested_tools is None else requested_tools
74
+ return selected & parent_tools
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Callable, Sequence
5
+ from pathlib import Path
6
+
7
+ from windcode.domain.subagents import VerificationResult
8
+ from windcode.domain.tools import ToolContext
9
+ from windcode.sandbox import BubblewrapSandbox
10
+ from windcode.tools.shell import ShellInput, ShellTool
11
+
12
+
13
+ def _resolve(path: Path) -> Path:
14
+ return path.expanduser().resolve()
15
+
16
+
17
+ class VerificationRunner:
18
+ def __init__(
19
+ self,
20
+ *,
21
+ sandbox: BubblewrapSandbox | None = None,
22
+ timeout_seconds: float = 120.0,
23
+ output_limit: int = 20_000,
24
+ ) -> None:
25
+ self.shell = ShellTool(
26
+ sandbox=sandbox,
27
+ default_timeout=timeout_seconds,
28
+ output_limit=output_limit,
29
+ )
30
+ self.output_limit = output_limit
31
+
32
+ async def run(
33
+ self,
34
+ commands: Sequence[str],
35
+ *,
36
+ workspace: Path,
37
+ run_id: str,
38
+ cancelled: Callable[[], bool] = lambda: False,
39
+ ) -> tuple[VerificationResult, ...]:
40
+ results: list[VerificationResult] = []
41
+ context = ToolContext(_resolve(workspace), run_id, cancelled)
42
+ for command in commands:
43
+ if cancelled():
44
+ raise asyncio.CancelledError
45
+ tool_result = await self.shell.execute(context, ShellInput(command=command))
46
+ exit_code_value = tool_result.data.get("exit_code")
47
+ exit_code = exit_code_value if isinstance(exit_code_value, int) else None
48
+ summary = tool_result.output[: self.output_limit]
49
+ result = VerificationResult(command, exit_code, summary, not tool_result.is_error)
50
+ results.append(result)
51
+ if not result.passed:
52
+ break
53
+ return tuple(results)
@@ -0,0 +1,3 @@
1
+ from windcode.sandbox.bwrap import BubblewrapSandbox, SandboxStatus, detect_bubblewrap
2
+
3
+ __all__ = ["BubblewrapSandbox", "SandboxStatus", "detect_bubblewrap"]
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class SandboxStatus:
10
+ available: bool
11
+ executable: Path | None
12
+ warning: str | None = None
13
+
14
+
15
+ def detect_bubblewrap(executable: str = "bwrap") -> SandboxStatus:
16
+ located = shutil.which(executable)
17
+ if located is None:
18
+ return SandboxStatus(
19
+ available=False,
20
+ executable=None,
21
+ warning="bubblewrap is unavailable; shell commands require elevated approval",
22
+ )
23
+ return SandboxStatus(available=True, executable=Path(located).resolve())
24
+
25
+
26
+ class BubblewrapSandbox:
27
+ def __init__(
28
+ self,
29
+ workspace: Path,
30
+ status: SandboxStatus | None = None,
31
+ *,
32
+ read_only_workspace: bool = False,
33
+ writable_paths: tuple[Path, ...] = (),
34
+ ) -> None:
35
+ self.workspace = workspace.expanduser().resolve()
36
+ self.status = status or detect_bubblewrap()
37
+ self.read_only_workspace = read_only_workspace
38
+ self.writable_paths = tuple(path.expanduser().resolve() for path in writable_paths)
39
+
40
+ def wrap(
41
+ self,
42
+ command: tuple[str, ...],
43
+ *,
44
+ cwd: Path | None = None,
45
+ allow_network: bool = False,
46
+ ) -> tuple[str, ...]:
47
+ if not self.status.available or self.status.executable is None:
48
+ raise RuntimeError(self.status.warning or "bubblewrap is unavailable")
49
+ working_directory = (cwd or self.workspace).resolve()
50
+ if not working_directory.is_relative_to(self.workspace):
51
+ raise ValueError("sandbox working directory must be inside the workspace")
52
+ temporary_mount = [] if self.workspace.is_relative_to(Path("/tmp")) else ["--tmpfs", "/tmp"]
53
+ workspace_mount = (
54
+ [] if self.read_only_workspace else ["--bind", str(self.workspace), str(self.workspace)]
55
+ )
56
+ additional_mounts = [
57
+ item for path in self.writable_paths for item in ("--bind", str(path), str(path))
58
+ ]
59
+ arguments = [
60
+ str(self.status.executable),
61
+ "--die-with-parent",
62
+ "--new-session",
63
+ "--ro-bind",
64
+ "/",
65
+ "/",
66
+ *workspace_mount,
67
+ *additional_mounts,
68
+ *temporary_mount,
69
+ "--proc",
70
+ "/proc",
71
+ "--dev",
72
+ "/dev",
73
+ "--chdir",
74
+ str(working_directory),
75
+ ]
76
+ if not allow_network:
77
+ arguments.append("--unshare-net")
78
+ arguments.extend(("--", *command))
79
+ return tuple(arguments)