crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,392 @@
1
+ """Agent management routes — /agent/*."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException, Request
6
+
7
+ from crabcode_gateway.schemas import (
8
+ AgentTranscriptResponse,
9
+ AgentInfo,
10
+ AgentInputRequest,
11
+ SpawnAgentRequest,
12
+ WaitAgentRequest,
13
+ )
14
+ from crabcode_gateway.session_registry import get_session_lock
15
+ from crabcode_gateway.safe_file import read_regular_file_tail
16
+ from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
17
+
18
+ router = APIRouter(prefix="/agent", tags=["agent"])
19
+
20
+
21
+ def _get_session(
22
+ request: Request,
23
+ session_id: str | None = None,
24
+ *,
25
+ agent_id: str | None = None,
26
+ agent_ids: list[str] | None = None,
27
+ ):
28
+ """Resolve a session, falling back to the owner of ``agent_id``.
29
+
30
+ Older clients rely on the process-wide default session. Keep that
31
+ behavior when no selector is supplied, but do not silently send an agent
32
+ operation to the wrong session when the requested agent lives elsewhere.
33
+ """
34
+ sessions: dict = request.app.state.sessions
35
+ closing = getattr(request.app.state, "closing_sessions", set())
36
+
37
+ # JSON/query selectors preserve an explicitly supplied empty string as an
38
+ # invalid selector; only ``None`` means the caller omitted it.
39
+ requested = session_id if session_id is not None else None
40
+ ids = [item for item in (agent_ids or []) if item]
41
+ if agent_id:
42
+ ids.append(agent_id)
43
+
44
+ def _owns(candidate: object | None) -> bool:
45
+ if candidate is None:
46
+ return False
47
+ if getattr(candidate, "session_id", None) in closing:
48
+ return False
49
+ if not ids:
50
+ return True
51
+ try:
52
+ agents = list(candidate.list_agents()) # type: ignore[attr-defined]
53
+ for selector in ids:
54
+ exact = [item for item in agents if item.agent_id == selector]
55
+ if exact:
56
+ continue
57
+ if len([item for item in agents if item.agent_id.startswith(selector)]) != 1:
58
+ return False
59
+ return True
60
+ except Exception:
61
+ return False
62
+
63
+ # An explicit selector is authoritative. It chooses the session only;
64
+ # agent existence is resolved inside the admitted operation so a missing
65
+ # agent is reported as an agent 404 rather than "Session not found".
66
+ if requested is not None:
67
+ selected = sessions.get(requested)
68
+ if selected is None or getattr(selected, "session_id", None) in closing:
69
+ return None
70
+ return selected
71
+
72
+ default_id = request.app.state.default_session_id
73
+ selected = sessions.get(default_id) if default_id else None
74
+ if _owns(selected):
75
+ return selected
76
+
77
+ if ids:
78
+ for candidate in sessions.values():
79
+ if candidate is selected:
80
+ continue
81
+ if _owns(candidate):
82
+ return candidate
83
+ return None
84
+
85
+
86
+ def _agent_info(snapshot) -> AgentInfo:
87
+ """Expose lifecycle and callback delivery state for monitoring clients."""
88
+ return AgentInfo(
89
+ agent_id=snapshot.agent_id,
90
+ session_id=snapshot.session_id,
91
+ parent_agent_id=snapshot.parent_agent_id,
92
+ parent_tool_use_id=getattr(snapshot, "parent_tool_use_id", None),
93
+ title=snapshot.title,
94
+ subagent_type=snapshot.subagent_type,
95
+ status=snapshot.status,
96
+ model=snapshot.model,
97
+ model_profile=getattr(snapshot, "model_profile", None),
98
+ created_at=snapshot.created_at,
99
+ started_at=getattr(snapshot, "started_at", None),
100
+ finished_at=snapshot.finished_at,
101
+ updated_at=getattr(snapshot, "updated_at", snapshot.created_at),
102
+ usage=snapshot.usage,
103
+ final_result=snapshot.final_result,
104
+ error=snapshot.error,
105
+ depth=getattr(snapshot, "depth", 0),
106
+ transcript_path=snapshot.transcript_path,
107
+ callback_enabled=snapshot.callback_enabled,
108
+ callback_state=snapshot.callback_state,
109
+ callback_message_id=snapshot.callback_message_id,
110
+ callback_epoch=snapshot.callback_epoch,
111
+ )
112
+
113
+
114
+ def _resolve_agent(session, selector: str):
115
+ """Resolve an exact id or a unique CLI-style id prefix."""
116
+ value = str(selector or "").strip()
117
+ if not value:
118
+ return None
119
+ exact = session.get_agent(value)
120
+ if exact is not None:
121
+ return exact
122
+ matches = [item for item in session.list_agents() if item.agent_id.startswith(value)]
123
+ return matches[0] if len(matches) == 1 else None
124
+
125
+
126
+ def _read_agent_tail(
127
+ session,
128
+ agent_id: str,
129
+ max_lines: int = 200,
130
+ ) -> tuple[str, list[str], bool]:
131
+ from crabcode_core.session.storage import get_agent_transcript_path
132
+
133
+ path = get_agent_transcript_path(
134
+ session.cwd,
135
+ session.session_id,
136
+ agent_id,
137
+ )
138
+ try:
139
+ lines, truncated = read_regular_file_tail(path, max_lines=max_lines)
140
+ except OSError:
141
+ return str(path), [], False
142
+ return str(path), lines, truncated
143
+
144
+
145
+ @router.post("/spawn", response_model=AgentInfo)
146
+ async def spawn_agent(req: SpawnAgentRequest, request: Request) -> AgentInfo:
147
+ """Spawn a managed sub-agent."""
148
+ session = _get_session(
149
+ request,
150
+ req.session_id
151
+ if req.session_id is not None
152
+ else request.query_params.get("session_id"),
153
+ )
154
+ if not session:
155
+ raise HTTPException(status_code=404, detail="Session not found")
156
+
157
+ async def _spawn_and_snapshot():
158
+ agent_id = await session.spawn_agent(
159
+ prompt=req.prompt,
160
+ subagent_type=req.subagent_type,
161
+ name=req.name,
162
+ model_profile=req.model_profile,
163
+ callback=req.callback,
164
+ )
165
+ return session.get_agent(agent_id)
166
+
167
+ try:
168
+ snapshot = await run_session_operation(
169
+ request.app.state,
170
+ session,
171
+ _spawn_and_snapshot,
172
+ )
173
+ except SessionOperationRejected as exc:
174
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
175
+ except (RuntimeError, ValueError) as exc:
176
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
177
+ if not snapshot:
178
+ raise HTTPException(status_code=500, detail="Agent spawn failed")
179
+
180
+ return _agent_info(snapshot)
181
+
182
+
183
+ @router.get("/list", response_model=list[AgentInfo])
184
+ async def list_agents(request: Request) -> list[AgentInfo]:
185
+ """List all managed agents."""
186
+ async with get_session_lock(request.app.state):
187
+ session = _get_session(request, request.query_params.get("session_id"))
188
+ if not session:
189
+ raise HTTPException(status_code=404, detail="Session not found")
190
+
191
+ async def _list():
192
+ initializer = getattr(session, "initialize", None)
193
+ if callable(initializer):
194
+ await initializer()
195
+ return list(session.list_agents())
196
+
197
+ try:
198
+ snapshots = await run_session_operation(request.app.state, session, _list)
199
+ except SessionOperationRejected as exc:
200
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
201
+
202
+ return [_agent_info(snapshot) for snapshot in snapshots]
203
+
204
+
205
+ @router.get("/{agent_id}", response_model=AgentInfo)
206
+ async def get_agent(agent_id: str, request: Request) -> AgentInfo:
207
+ """Get a specific agent's status."""
208
+ async with get_session_lock(request.app.state):
209
+ session = _get_session(
210
+ request,
211
+ request.query_params.get("session_id"),
212
+ agent_id=agent_id,
213
+ )
214
+ if not session:
215
+ raise HTTPException(status_code=404, detail="Session not found")
216
+
217
+ async def _get():
218
+ initializer = getattr(session, "initialize", None)
219
+ if callable(initializer):
220
+ await initializer()
221
+ return _resolve_agent(session, agent_id)
222
+
223
+ try:
224
+ snapshot = await run_session_operation(request.app.state, session, _get)
225
+ except SessionOperationRejected as exc:
226
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
227
+ if not snapshot:
228
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
229
+
230
+ return _agent_info(snapshot)
231
+
232
+
233
+ @router.get("/{agent_id}/transcript", response_model=AgentTranscriptResponse)
234
+ @router.get("/{agent_id}/log", response_model=AgentTranscriptResponse)
235
+ async def get_agent_transcript(agent_id: str, request: Request) -> AgentTranscriptResponse:
236
+ """Return the stored tail of an agent transcript for desktop clients."""
237
+ async with get_session_lock(request.app.state):
238
+ session = _get_session(
239
+ request,
240
+ request.query_params.get("session_id"),
241
+ agent_id=agent_id,
242
+ )
243
+ if not session:
244
+ raise HTTPException(status_code=404, detail="Session not found")
245
+
246
+ async def _get():
247
+ initializer = getattr(session, "initialize", None)
248
+ if callable(initializer):
249
+ await initializer()
250
+ return _resolve_agent(session, agent_id)
251
+
252
+ try:
253
+ snapshot = await run_session_operation(request.app.state, session, _get)
254
+ except SessionOperationRejected as exc:
255
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
256
+ if not snapshot:
257
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
258
+ try:
259
+ limit = max(1, min(10_000, int(request.query_params.get("lines", "200"))))
260
+ except (TypeError, ValueError):
261
+ raise HTTPException(status_code=400, detail="lines must be an integer")
262
+ path, lines, truncated = _read_agent_tail(session, snapshot.agent_id, limit)
263
+ return AgentTranscriptResponse(
264
+ agent_id=snapshot.agent_id,
265
+ path=path,
266
+ lines=lines,
267
+ truncated=truncated,
268
+ )
269
+
270
+
271
+ @router.post("/{agent_id}/cancel")
272
+ async def cancel_agent(agent_id: str, request: Request):
273
+ """Cancel a running agent."""
274
+ session = _get_session(
275
+ request,
276
+ request.query_params.get("session_id"),
277
+ agent_id=agent_id,
278
+ )
279
+ if not session:
280
+ raise HTTPException(status_code=404, detail="Session not found")
281
+
282
+ async def _cancel():
283
+ initializer = getattr(session, "initialize", None)
284
+ if callable(initializer):
285
+ await initializer()
286
+ snapshot = _resolve_agent(session, agent_id)
287
+ if snapshot is None:
288
+ return None
289
+ return await session.cancel_agent(snapshot.agent_id)
290
+
291
+ try:
292
+ ok = await run_session_operation(
293
+ request.app.state,
294
+ session,
295
+ _cancel,
296
+ )
297
+ except SessionOperationRejected as exc:
298
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
299
+ if ok is None:
300
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
301
+ if not ok:
302
+ raise HTTPException(status_code=400, detail=f"Could not cancel agent {agent_id}")
303
+ return {"status": "ok"}
304
+
305
+
306
+ @router.post("/{agent_id}/input")
307
+ async def send_agent_input(agent_id: str, req: AgentInputRequest, request: Request):
308
+ """Send additional input to an agent."""
309
+ session = _get_session(
310
+ request,
311
+ req.session_id
312
+ if req.session_id is not None
313
+ else request.query_params.get("session_id"),
314
+ agent_id=agent_id,
315
+ )
316
+ if not session:
317
+ raise HTTPException(status_code=404, detail="Session not found")
318
+
319
+ async def _send_input():
320
+ initializer = getattr(session, "initialize", None)
321
+ if callable(initializer):
322
+ await initializer()
323
+ snapshot = _resolve_agent(session, agent_id)
324
+ if snapshot is None:
325
+ return None
326
+ return await session.send_agent_input(
327
+ snapshot.agent_id,
328
+ req.prompt,
329
+ interrupt=req.interrupt,
330
+ )
331
+
332
+ try:
333
+ ok = await run_session_operation(
334
+ request.app.state,
335
+ session,
336
+ _send_input,
337
+ )
338
+ except SessionOperationRejected as exc:
339
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
340
+ if ok is None:
341
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
342
+ if not ok:
343
+ raise HTTPException(status_code=400, detail=f"Could not send input to agent {agent_id}")
344
+ return {"status": "ok"}
345
+
346
+
347
+ @router.post("/wait", response_model=AgentInfo)
348
+ async def wait_agent(req: WaitAgentRequest, request: Request) -> AgentInfo:
349
+ """Wait for one or more agents to complete."""
350
+ requested_session_id = (
351
+ req.session_id
352
+ if req.session_id is not None
353
+ else request.query_params.get("session_id")
354
+ )
355
+ agent_ids = req.agent_id if isinstance(req.agent_id, list) else [req.agent_id]
356
+ session = _get_session(
357
+ request,
358
+ requested_session_id,
359
+ agent_ids=agent_ids,
360
+ )
361
+ if not session:
362
+ raise HTTPException(status_code=404, detail="Session not found")
363
+
364
+ async def _resolve_and_wait():
365
+ initializer = getattr(session, "initialize", None)
366
+ if callable(initializer):
367
+ await initializer()
368
+ resolved_ids: list[str] = []
369
+ for selector in agent_ids:
370
+ snapshot = _resolve_agent(session, selector)
371
+ if snapshot is None:
372
+ return selector, None
373
+ resolved_ids.append(snapshot.agent_id)
374
+ target: str | list[str] = (
375
+ resolved_ids if isinstance(req.agent_id, list) else resolved_ids[0]
376
+ )
377
+ return None, await session.wait_agent(target, timeout_ms=req.timeout_ms)
378
+
379
+ try:
380
+ missing_selector, snapshot = await run_session_operation(
381
+ request.app.state,
382
+ session,
383
+ _resolve_and_wait,
384
+ )
385
+ except SessionOperationRejected as exc:
386
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
387
+ if missing_selector is not None:
388
+ raise HTTPException(status_code=404, detail=f"Agent {missing_selector} not found")
389
+ if not snapshot:
390
+ raise HTTPException(status_code=408, detail="Agent wait timed out")
391
+
392
+ return _agent_info(snapshot)
@@ -0,0 +1,150 @@
1
+ """Authentication endpoints used to bootstrap short-lived gateway JWTs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import time
7
+ from typing import Annotated, Literal
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+ from pydantic import BaseModel, Field
11
+
12
+ from crabcode_gateway.auth import new_token, verify_public_signature
13
+
14
+ router = APIRouter(prefix="/auth", tags=["auth"])
15
+
16
+ _PASSWORD_WINDOW_SECONDS = 60
17
+ _PASSWORD_MAX_FAILURES = 5
18
+
19
+
20
+ class PasswordTokenRequest(BaseModel):
21
+ grant_type: Literal["password"]
22
+ password: str = Field(min_length=1)
23
+
24
+
25
+ class PublicKeyTokenRequest(BaseModel):
26
+ grant_type: Literal["publickey"]
27
+ key_id: str = Field(min_length=1)
28
+ challenge: str = Field(min_length=1)
29
+ signature: str = Field(min_length=1)
30
+
31
+
32
+ def _password_client(request: Request) -> str:
33
+ return request.client.host if request.client else "unknown"
34
+
35
+
36
+ def _check_password_rate_limit(request: Request) -> tuple[str, list[float]]:
37
+ client = _password_client(request)
38
+ cutoff = time.monotonic() - _PASSWORD_WINDOW_SECONDS
39
+ failures = [
40
+ value
41
+ for value in request.app.state.auth_failures.get(client, [])
42
+ if value >= cutoff
43
+ ]
44
+ request.app.state.auth_failures[client] = failures
45
+ if len(failures) >= _PASSWORD_MAX_FAILURES:
46
+ raise HTTPException(
47
+ status_code=429,
48
+ detail="Too many authentication failures; retry later",
49
+ headers={"Retry-After": str(_PASSWORD_WINDOW_SECONDS)},
50
+ )
51
+ return client, failures
52
+
53
+
54
+ @router.get("/challenge")
55
+ async def challenge(request: Request) -> dict[str, str | int]:
56
+ security = request.app.state.gateway_security
57
+ if security.mode not in ("publickey", "mixed"):
58
+ raise HTTPException(
59
+ status_code=404,
60
+ detail="Public-key authentication is disabled",
61
+ )
62
+ nonce = secrets.token_urlsafe(32)
63
+ expires_at = int(time.time()) + 60
64
+ now = int(time.time())
65
+ challenges = request.app.state.auth_challenges
66
+ for value, expiry in list(challenges.items()):
67
+ if expiry < now:
68
+ challenges.pop(value, None)
69
+ if len(challenges) >= 10_000:
70
+ raise HTTPException(status_code=503, detail="Too many pending challenges")
71
+ challenges[nonce] = expires_at
72
+ signing_payload = f"crabcode-gateway:{nonce}:{expires_at}"
73
+ return {
74
+ "challenge": nonce,
75
+ "expires_at": expires_at,
76
+ "signing_payload": signing_payload,
77
+ }
78
+
79
+
80
+ @router.get("/info")
81
+ async def info(request: Request) -> dict[str, object]:
82
+ security = request.app.state.gateway_security
83
+ methods: list[str] = []
84
+ if security.mode in ("password", "mixed"):
85
+ methods.append("password")
86
+ if security.mode in ("publickey", "mixed"):
87
+ methods.append("publickey")
88
+ return {
89
+ "mode": security.mode,
90
+ "methods": methods,
91
+ "token_ttl_seconds": security.token_ttl_seconds,
92
+ }
93
+
94
+
95
+ @router.post("/token")
96
+ async def token(
97
+ request: Request,
98
+ body: Annotated[
99
+ PasswordTokenRequest | PublicKeyTokenRequest,
100
+ Field(discriminator="grant_type"),
101
+ ],
102
+ ):
103
+ security = request.app.state.gateway_security
104
+ secret = request.app.state.gateway_jwt_secret
105
+ if isinstance(body, PasswordTokenRequest):
106
+ client, failures = _check_password_rate_limit(request)
107
+ if security.mode not in (
108
+ "password",
109
+ "mixed",
110
+ ) or not request.app.state.verify_gateway_password(body.password):
111
+ failures.append(time.monotonic())
112
+ raise HTTPException(status_code=401, detail="Invalid password")
113
+ request.app.state.auth_failures.pop(client, None)
114
+ return {
115
+ "access_token": new_token(
116
+ subject="password",
117
+ mode="password",
118
+ secret=secret,
119
+ ttl=security.token_ttl_seconds,
120
+ ),
121
+ "token_type": "bearer",
122
+ "expires_in": security.token_ttl_seconds,
123
+ }
124
+
125
+ if security.mode not in ("publickey", "mixed"):
126
+ raise HTTPException(
127
+ status_code=404,
128
+ detail="Public-key authentication is disabled",
129
+ )
130
+ expires_at = request.app.state.auth_challenges.pop(body.challenge, None)
131
+ if not expires_at or expires_at < int(time.time()):
132
+ raise HTTPException(status_code=401, detail="Challenge expired or already used")
133
+ message = f"crabcode-gateway:{body.challenge}:{expires_at}".encode()
134
+ if not verify_public_signature(
135
+ security.authorized_keys,
136
+ body.key_id,
137
+ message,
138
+ body.signature,
139
+ ):
140
+ raise HTTPException(status_code=401, detail="Invalid public-key signature")
141
+ return {
142
+ "access_token": new_token(
143
+ subject=body.key_id,
144
+ mode="publickey",
145
+ secret=secret,
146
+ ttl=security.token_ttl_seconds,
147
+ ),
148
+ "token_type": "bearer",
149
+ "expires_in": security.token_ttl_seconds,
150
+ }
@@ -0,0 +1,101 @@
1
+ """Dedicated WebSocket transport for Desktop Computer Use hosts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
8
+
9
+ from crabcode_core.logging_utils import get_logger
10
+ from crabcode_gateway.session_registry import get_session_lock
11
+
12
+ logger = get_logger(__name__)
13
+ router = APIRouter(tags=["computer-use"])
14
+
15
+
16
+ async def _sync_bound_sessions(state: Any, host_id: str, enabled: bool) -> None:
17
+ async with get_session_lock(state):
18
+ for session in state.sessions.values():
19
+ if getattr(session, "computer_use_host_id", None) == host_id:
20
+ session.computer_use_host_available = enabled
21
+ # A reconnect is availability, not user intent. Legacy sessions
22
+ # without an explicit choice keep their historical behavior.
23
+ session.computer_use_enabled = getattr(session, "_computer_use_requested_enabled", enabled)
24
+
25
+
26
+ @router.websocket("/computer-use/ws")
27
+ async def computer_use_socket(websocket: WebSocket) -> None:
28
+ await websocket.accept()
29
+ broker = websocket.app.state.computer_use_broker
30
+ host_id: str | None = None
31
+ try:
32
+ while True:
33
+ message: dict[str, Any] = await websocket.receive_json()
34
+ kind = str(message.get("type") or "")
35
+ if kind == "computer_use_host_register":
36
+ requested_id = str(message.get("host_id") or "").strip()
37
+ if not requested_id or len(requested_id) > 200:
38
+ await websocket.send_json({"type": "computer_use_error", "error": "invalid host_id"})
39
+ continue
40
+ if host_id and host_id != requested_id:
41
+ broker.unregister(host_id, websocket)
42
+ host_id = requested_id
43
+ capabilities = message.get("capabilities")
44
+ broker.register(
45
+ host_id,
46
+ websocket,
47
+ enabled=message.get("enabled") is True,
48
+ gui_available=message.get("gui_available") is True,
49
+ capabilities=capabilities if isinstance(capabilities, dict) else {},
50
+ )
51
+ available = broker.is_available(host_id)
52
+ await _sync_bound_sessions(websocket.app.state, host_id, available)
53
+ await websocket.send_json({
54
+ "type": "computer_use_host_registered",
55
+ "host_id": host_id,
56
+ "available": available,
57
+ "previews": broker.restorable_previews(host_id),
58
+ })
59
+ continue
60
+
61
+ if host_id is None:
62
+ await websocket.send_json({"type": "computer_use_error", "error": "host is not registered"})
63
+ continue
64
+
65
+ if kind == "computer_use_host_state":
66
+ capabilities = message.get("capabilities")
67
+ updated = broker.update_state(
68
+ host_id,
69
+ websocket,
70
+ enabled=message.get("enabled") is True,
71
+ gui_available=message.get("gui_available") is True,
72
+ capabilities=capabilities if isinstance(capabilities, dict) else None,
73
+ )
74
+ available = broker.is_available(host_id) if updated else False
75
+ if updated:
76
+ await _sync_bound_sessions(websocket.app.state, host_id, available)
77
+ await websocket.send_json({
78
+ "type": "computer_use_host_state_ack",
79
+ "available": available,
80
+ })
81
+ elif kind == "computer_use_result":
82
+ request_id = str(message.get("request_id") or "")
83
+ result = message.get("result")
84
+ preview = message.get("preview_screenshot")
85
+ broker.resolve(
86
+ host_id,
87
+ request_id,
88
+ result if isinstance(result, dict) else {"ok": False, "error": "invalid host result"},
89
+ preview_screenshot=preview if isinstance(preview, dict) else None,
90
+ )
91
+ else:
92
+ await websocket.send_json({"type": "computer_use_error", "error": f"unknown message type: {kind}"})
93
+ except WebSocketDisconnect:
94
+ pass
95
+ except Exception:
96
+ logger.warning("Computer Use host socket failed", exc_info=True)
97
+ finally:
98
+ if host_id:
99
+ broker.unregister(host_id, websocket)
100
+ if not broker.is_available(host_id):
101
+ await _sync_bound_sessions(websocket.app.state, host_id, False)