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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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)
|