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,114 @@
1
+ """ACP session manager — tracks ACP-side session state.
2
+
3
+ Each ACP session wraps an internal CrabCode session (created via the
4
+ Gateway REST API). The manager holds the mapping between ACP session
5
+ IDs and their associated state (cwd, model, mode, MCP servers).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ import httpx
13
+ from acp.exceptions import RequestError
14
+ from acp.schema import HttpMcpServer, McpServerStdio, SseMcpServer
15
+
16
+ from crabcode_core.logging_utils import get_logger
17
+ from crabcode_gateway.acp.types import ACPConfig, ACPSessionState, ModelSelection
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class ACPSessionManager:
23
+ """Manages ACP session lifecycle backed by the Gateway HTTP API."""
24
+
25
+ def __init__(self, config: ACPConfig) -> None:
26
+ self._config = config
27
+ self._sessions: dict[str, ACPSessionState] = {}
28
+ self._client = httpx.AsyncClient(base_url=config.base_url, timeout=60.0)
29
+
30
+ # ── CRUD ────────────────────────────────────────────────────
31
+
32
+ async def create(
33
+ self,
34
+ cwd: str,
35
+ mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer],
36
+ model: ModelSelection | None = None,
37
+ ) -> ACPSessionState:
38
+ """Create a new session via the Gateway API and store ACP state."""
39
+ resp = await self._client.post("/session/new", json={"cwd": cwd})
40
+ resp.raise_for_status()
41
+ data = resp.json()
42
+ session_id = data["session_id"]
43
+
44
+ state = ACPSessionState(
45
+ id=session_id,
46
+ cwd=cwd,
47
+ mcp_servers=mcp_servers,
48
+ created_at=time.time(),
49
+ model=model,
50
+ )
51
+ self._sessions[session_id] = state
52
+ logger.info("acp_session_created", extra={"session_id": session_id})
53
+ return state
54
+
55
+ async def load(
56
+ self,
57
+ session_id: str,
58
+ cwd: str,
59
+ mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer],
60
+ model: ModelSelection | None = None,
61
+ ) -> ACPSessionState:
62
+ """Load an existing session via the Gateway API."""
63
+ resp = await self._client.post("/session/resume", json={"session_id": session_id})
64
+ resp.raise_for_status()
65
+
66
+ state = ACPSessionState(
67
+ id=session_id,
68
+ cwd=cwd,
69
+ mcp_servers=mcp_servers,
70
+ created_at=time.time(),
71
+ model=model,
72
+ )
73
+ self._sessions[session_id] = state
74
+ logger.info("acp_session_loaded", extra={"session_id": session_id})
75
+ return state
76
+
77
+ def try_get(self, session_id: str) -> ACPSessionState | None:
78
+ """Return session state if it exists, else None."""
79
+ return self._sessions.get(session_id)
80
+
81
+ def get(self, session_id: str) -> ACPSessionState:
82
+ """Return session state or raise ACP RequestError."""
83
+ state = self._sessions.get(session_id)
84
+ if not state:
85
+ raise RequestError(code=-32602, message=f"Session not found: {session_id}")
86
+ return state
87
+
88
+ def set_model(self, session_id: str, model: ModelSelection) -> None:
89
+ state = self.get(session_id)
90
+ state.model = model
91
+
92
+ def get_model(self, session_id: str) -> ModelSelection | None:
93
+ return self.get(session_id).model
94
+
95
+ def set_variant(self, session_id: str, variant: str | None) -> None:
96
+ self.get(session_id).variant = variant
97
+
98
+ def get_variant(self, session_id: str) -> str | None:
99
+ return self.get(session_id).variant
100
+
101
+ def set_mode(self, session_id: str, mode_id: str) -> None:
102
+ self.get(session_id).mode_id = mode_id
103
+
104
+ def get_mode(self, session_id: str) -> str | None:
105
+ return self.get(session_id).mode_id
106
+
107
+ # ── HTTP helpers exposed to Agent ───────────────────────────
108
+
109
+ @property
110
+ def client(self) -> httpx.AsyncClient:
111
+ return self._client
112
+
113
+ async def aclose(self) -> None:
114
+ await self._client.aclose()
@@ -0,0 +1,39 @@
1
+ """ACP stdio transport — wires CrabCodeACPAgent to stdin/stdout via ACP SDK.
2
+
3
+ Uses the official `acp.run_agent` convenience function which handles:
4
+ - JSON-RPC framing over ndjson stdio streams
5
+ - Request/response routing
6
+ - Agent-side connection lifecycle
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import acp
12
+
13
+ from crabcode_core.logging_utils import get_logger
14
+ from crabcode_gateway.acp.agent import CrabCodeACPAgent
15
+ from crabcode_gateway.acp.types import ACPConfig
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ async def run_acp_server(config: ACPConfig) -> None:
21
+ """Start the ACP agent server on stdio.
22
+
23
+ This function blocks until stdin is closed or a fatal error occurs.
24
+
25
+ Usage::
26
+
27
+ config = ACPConfig(base_url="http://127.0.0.1:4096")
28
+ await run_acp_server(config)
29
+ """
30
+ agent = CrabCodeACPAgent(config)
31
+ try:
32
+ logger.info("acp_server_starting", extra={"base_url": config.base_url})
33
+ await acp.run_agent(agent)
34
+ except Exception:
35
+ logger.exception("acp_server_error")
36
+ raise
37
+ finally:
38
+ await agent.close()
39
+ logger.info("acp_server_stopped")
@@ -0,0 +1,90 @@
1
+ """ACP internal types — session state and configuration.
2
+
3
+ Maps CrabCode session/model concepts to ACP's expectations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from acp.schema import McpServerStdio, HttpMcpServer, SseMcpServer
12
+
13
+
14
+ @dataclass
15
+ class ACPSessionState:
16
+ """Tracks ACP-specific state for a single session.
17
+
18
+ Stored in ACPSessionManager; not persisted to disk.
19
+ """
20
+
21
+ id: str
22
+ cwd: str
23
+ mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] = field(default_factory=list)
24
+ created_at: float = 0.0
25
+ model: ModelSelection | None = None
26
+ mode_id: str | None = None
27
+ variant: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class ModelSelection:
32
+ """Identifies a provider + model pair."""
33
+
34
+ provider_id: str
35
+ model_id: str
36
+
37
+
38
+ @dataclass
39
+ class ACPConfig:
40
+ """Top-level configuration passed to the ACP Agent on construction."""
41
+
42
+ base_url: str # e.g. "http://127.0.0.1:4096"
43
+ default_model: ModelSelection | None = None
44
+
45
+
46
+ # ── Tool kind mapping ──────────────────────────────────────────
47
+
48
+
49
+ def to_tool_kind(tool_name: str) -> str:
50
+ """Map CrabCode tool names to ACP ToolKind literals.
51
+
52
+ ACP ToolKind: read | edit | delete | move | search | execute |
53
+ think | fetch | switch_mode | other
54
+ """
55
+ tool = tool_name.lower()
56
+ if tool == "bash":
57
+ return "execute"
58
+ if tool == "web_search":
59
+ return "fetch"
60
+ if tool in ("applypatch", "apply_patch", "edit", "patch", "file_edit", "file_write", "write"):
61
+ return "edit"
62
+ if tool in ("grep", "glob", "toolsearch"):
63
+ return "search"
64
+ if tool == "read" or tool == "file_read":
65
+ return "read"
66
+ if tool == "delete":
67
+ return "delete"
68
+ if tool == "switch_mode":
69
+ return "switch_mode"
70
+ return "other"
71
+
72
+
73
+ def to_locations(tool_name: str, tool_input: dict[str, Any]) -> list[dict[str, str]]:
74
+ """Extract file paths from tool input for ACP ToolCallLocation."""
75
+ tool = tool_name.lower()
76
+ if tool in ("read", "file_read", "edit", "file_edit", "write", "file_write"):
77
+ path = tool_input.get("filePath") or tool_input.get("file_path") or ""
78
+ return [{"path": path}] if path else []
79
+ if tool in ("applypatch", "apply_patch", "patch"):
80
+ try:
81
+ from crabcode_core.tools.apply_patch import patch_paths
82
+
83
+ paths = tool_input.get("affected_paths") or patch_paths(str(tool_input.get("patch") or ""))
84
+ except (TypeError, ValueError):
85
+ paths = []
86
+ return [{"path": path} for path in paths if isinstance(path, str) and path]
87
+ if tool in ("glob", "grep"):
88
+ path = tool_input.get("path") or ""
89
+ return [{"path": path}] if path else []
90
+ return []
@@ -0,0 +1,30 @@
1
+ """Protocol adapter abstraction — inspired by OpenCode's adapter.ts.
2
+
3
+ Each concrete adapter (HTTP, gRPC) implements the ProtocolAdapter ABC,
4
+ allowing the server to start/stop multiple protocols uniformly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+
11
+
12
+ class ProtocolAdapter(ABC):
13
+ """Abstract base class for protocol adapters.
14
+
15
+ Mirrors OpenCode's Adapter interface which abstracts away the
16
+ runtime-specific server creation (Bun vs Node).
17
+ """
18
+
19
+ @abstractmethod
20
+ async def start(self, host: str, port: int) -> None:
21
+ """Start serving on the given host:port."""
22
+
23
+ @abstractmethod
24
+ async def stop(self) -> None:
25
+ """Gracefully stop the server."""
26
+
27
+ @property
28
+ @abstractmethod
29
+ def is_running(self) -> bool:
30
+ """Whether the server is currently running."""
@@ -0,0 +1,139 @@
1
+ """Gateway authentication: password/public-key login and short-lived JWTs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import os
9
+ import secrets
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import jwt
15
+
16
+ try:
17
+ from cryptography.hazmat.primitives import hashes, serialization
18
+ from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
19
+ except ImportError: # pragma: no cover - dependency is declared by gateway
20
+ serialization = None # type: ignore[assignment]
21
+
22
+
23
+ def _b64(data: bytes) -> str:
24
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
25
+
26
+
27
+ def _unb64(value: str) -> bytes:
28
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
29
+
30
+
31
+ def make_jwt(payload: dict[str, Any], secret: str) -> str:
32
+ return jwt.encode(payload, secret, algorithm="HS256")
33
+
34
+
35
+ def decode_jwt(token: str, secret: str) -> dict[str, Any] | None:
36
+ try:
37
+ return jwt.decode(
38
+ token,
39
+ secret,
40
+ algorithms=["HS256"],
41
+ issuer="crabcode-gateway",
42
+ audience="crabcode-api",
43
+ options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub", "jti"]},
44
+ )
45
+ except jwt.PyJWTError:
46
+ return None
47
+
48
+
49
+ def hash_password(password: str, *, iterations: int = 310_000) -> str:
50
+ salt = os.urandom(16)
51
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
52
+ return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(digest)}"
53
+
54
+
55
+ def verify_password(password: str, encoded: str) -> bool:
56
+ try:
57
+ algorithm, raw_iterations, salt, expected = encoded.split("$", 3)
58
+ if algorithm != "pbkdf2_sha256":
59
+ return False
60
+ iterations = int(raw_iterations)
61
+ if iterations < 100_000 or iterations > 2_000_000:
62
+ return False
63
+ actual = hashlib.pbkdf2_hmac("sha256", password.encode(), _unb64(salt), iterations)
64
+ return hmac.compare_digest(_b64(actual), expected)
65
+ except (ValueError, TypeError):
66
+ return False
67
+
68
+
69
+ def _authorized_keys(path: str) -> dict[str, Any]:
70
+ """Read an OpenSSH authorized_keys file and return id -> public key."""
71
+ keys: dict[str, Any] = {}
72
+ expanded = Path(path).expanduser()
73
+ if serialization is None or not expanded.is_file():
74
+ return keys
75
+ try:
76
+ lines = expanded.read_text(encoding="utf-8", errors="replace").splitlines()
77
+ except OSError:
78
+ return keys
79
+ for line in lines:
80
+ line = line.strip()
81
+ if not line or line.startswith("#"):
82
+ continue
83
+ fields = line.split()
84
+ try:
85
+ idx = next(
86
+ i
87
+ for i, value in enumerate(fields)
88
+ if value.startswith(("ssh-", "ecdsa-", "sk-"))
89
+ )
90
+ key_type, encoded = fields[idx], fields[idx + 1]
91
+ raw = base64.b64decode(encoded, validate=True)
92
+ public_key = serialization.load_ssh_public_key(
93
+ f"{key_type} {encoded}".encode()
94
+ )
95
+ fingerprint = "SHA256:" + _b64(hashlib.sha256(raw).digest())
96
+ comment = " ".join(fields[idx + 2:]).strip()
97
+ key_id = comment or fingerprint
98
+ keys[key_id] = public_key
99
+ keys.setdefault(fingerprint, public_key)
100
+ except (StopIteration, ValueError, IndexError, TypeError):
101
+ continue
102
+ return keys
103
+
104
+
105
+ def verify_public_signature(path: str, key_id: str, message: bytes, signature_b64: str) -> bool:
106
+ keys = _authorized_keys(path)
107
+ public_key = keys.get(key_id)
108
+ if public_key is None:
109
+ return False
110
+ try:
111
+ signature = base64.b64decode(signature_b64, validate=True)
112
+ if isinstance(public_key, ed25519.Ed25519PublicKey):
113
+ public_key.verify(signature, message)
114
+ elif isinstance(public_key, rsa.RSAPublicKey):
115
+ public_key.verify(signature, message, padding.PKCS1v15(), hashes.SHA256())
116
+ elif isinstance(public_key, ec.EllipticCurvePublicKey):
117
+ public_key.verify(signature, message, ec.ECDSA(hashes.SHA256()))
118
+ else:
119
+ return False
120
+ return True
121
+ except Exception:
122
+ return False
123
+
124
+
125
+ def new_token(*, subject: str, mode: str, secret: str, ttl: int) -> str:
126
+ now = int(time.time())
127
+ return make_jwt(
128
+ {
129
+ "iss": "crabcode-gateway",
130
+ "aud": "crabcode-api",
131
+ "sub": subject,
132
+ "auth_mode": mode,
133
+ "iat": now,
134
+ "nbf": now,
135
+ "exp": now + ttl,
136
+ "jti": secrets.token_hex(16),
137
+ },
138
+ secret,
139
+ )