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,299 @@
1
+ """Workspace discovery and scoped directory creation for desktop clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import os
7
+ import platform
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+ from fastapi.responses import FileResponse
13
+
14
+ from crabcode_gateway import __version__
15
+ from crabcode_gateway.schemas import (
16
+ GatewayRuntimeInfo,
17
+ WorkspaceDirectoryEntry,
18
+ WorkspaceDirectoryCreateRequest,
19
+ WorkspaceDirectoryListing,
20
+ WorkspaceFileEntry,
21
+ WorkspaceInfo,
22
+ )
23
+
24
+ router = APIRouter(prefix="/workspace", tags=["workspace"])
25
+
26
+ MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024
27
+ MAX_IMAGE_PREVIEW_BYTES = 10 * 1024 * 1024
28
+ _IMAGE_PREVIEW_EXTENSIONS = {".gif", ".jpeg", ".jpg", ".png", ".webp"}
29
+ _TEXT_PREVIEW_EXTENSIONS = {
30
+ ".bash", ".bat", ".c", ".cc", ".cfg", ".cjs", ".cmd", ".conf", ".cpp",
31
+ ".css", ".cts", ".cxx", ".env", ".fish", ".gitattributes", ".gitignore",
32
+ ".go", ".gql", ".graphql", ".h", ".hpp", ".htm", ".html", ".ini", ".java",
33
+ ".js", ".json", ".jsonc", ".jsx", ".kt", ".kts", ".less", ".lock", ".md",
34
+ ".markdown", ".mdx", ".mjs", ".mts", ".php", ".proto", ".ps1", ".py", ".pyi",
35
+ ".rb", ".rs", ".sass", ".scss", ".sh", ".sql", ".svg", ".swift", ".toml",
36
+ ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml", ".zsh",
37
+ }
38
+ _TEXT_PREVIEW_FILENAMES = {
39
+ ".dockerignore", ".editorconfig", ".eslintignore", ".eslintrc", ".gitattributes",
40
+ ".gitignore", ".npmrc", ".prettierignore", ".prettierrc", ".rgignore",
41
+ ".stylelintignore", ".stylelintrc", "dockerfile", "gemfile", "license", "makefile",
42
+ "procfile", "readme",
43
+ }
44
+ _MARKDOWN_PREVIEW_EXTENSIONS = {".md", ".markdown", ".mdx"}
45
+
46
+
47
+ def _is_within(path: Path, roots: tuple[Path, ...]) -> bool:
48
+ normalized_path = os.path.normcase(str(path))
49
+ for root in roots:
50
+ normalized_root = os.path.normcase(str(root))
51
+ try:
52
+ if os.path.commonpath((normalized_path, normalized_root)) == normalized_root:
53
+ return True
54
+ except ValueError:
55
+ # Different drives on Windows cannot share a common path.
56
+ continue
57
+ return False
58
+
59
+
60
+ def _resolve_directory(value: str, roots: tuple[Path, ...]) -> Path:
61
+ candidate = Path(value).expanduser()
62
+ if not candidate.is_absolute():
63
+ raise HTTPException(status_code=400, detail="path must be absolute")
64
+ try:
65
+ resolved = candidate.resolve(strict=True)
66
+ except FileNotFoundError as exc:
67
+ raise HTTPException(status_code=404, detail="Directory not found") from exc
68
+ except OSError as exc:
69
+ raise HTTPException(status_code=400, detail="Unable to resolve directory") from exc
70
+ if not resolved.is_dir():
71
+ raise HTTPException(status_code=400, detail="path must be a directory")
72
+ if not _is_within(resolved, roots):
73
+ raise HTTPException(status_code=403, detail="Directory is outside the allowed browse roots")
74
+ return resolved
75
+
76
+
77
+ def _resolve_preview_file(value: str, roots: tuple[Path, ...]) -> Path:
78
+ candidate = Path(value).expanduser()
79
+ if not candidate.is_absolute():
80
+ raise HTTPException(status_code=400, detail="path must be absolute")
81
+ try:
82
+ resolved = candidate.resolve(strict=True)
83
+ except FileNotFoundError as exc:
84
+ raise HTTPException(status_code=404, detail="File not found") from exc
85
+ except OSError as exc:
86
+ raise HTTPException(status_code=400, detail="Unable to resolve file") from exc
87
+ if not _is_within(resolved, roots):
88
+ raise HTTPException(status_code=403, detail="File is outside the allowed browse roots")
89
+ if not resolved.is_file():
90
+ raise HTTPException(status_code=400, detail="path must be a regular file")
91
+ return resolved
92
+
93
+
94
+ def _preview_media_type(path: Path) -> tuple[str, int]:
95
+ suffix = path.suffix.casefold()
96
+ name = path.name.casefold()
97
+ if suffix in _IMAGE_PREVIEW_EXTENSIONS:
98
+ media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
99
+ return media_type, MAX_IMAGE_PREVIEW_BYTES
100
+ if (
101
+ suffix in _TEXT_PREVIEW_EXTENSIONS
102
+ or name in _TEXT_PREVIEW_FILENAMES
103
+ or name.startswith(".env")
104
+ ):
105
+ media_type = (
106
+ "text/markdown"
107
+ if suffix in _MARKDOWN_PREVIEW_EXTENSIONS
108
+ else mimetypes.guess_type(path.name)[0] or "text/plain"
109
+ )
110
+ if media_type == "image/svg+xml":
111
+ media_type = "text/plain"
112
+ return media_type, MAX_TEXT_PREVIEW_BYTES
113
+ raise HTTPException(status_code=415, detail="File type is not supported for preview")
114
+
115
+
116
+ def _create_directory(value: str, roots: tuple[Path, ...]) -> Path:
117
+ candidate = Path(value).expanduser()
118
+ if not candidate.is_absolute():
119
+ raise HTTPException(status_code=400, detail="path must be absolute")
120
+ if not candidate.name or candidate.name in {".", ".."}:
121
+ raise HTTPException(status_code=400, detail="path must name a directory")
122
+ try:
123
+ parent = candidate.parent.resolve(strict=True)
124
+ except FileNotFoundError as exc:
125
+ raise HTTPException(status_code=404, detail="Parent directory not found") from exc
126
+ except OSError as exc:
127
+ raise HTTPException(status_code=400, detail="Unable to resolve parent directory") from exc
128
+ if not parent.is_dir() or not _is_within(parent, roots):
129
+ raise HTTPException(status_code=403, detail="Directory is outside the allowed browse roots")
130
+ try:
131
+ candidate.mkdir(exist_ok=True)
132
+ resolved = candidate.resolve(strict=True)
133
+ except FileExistsError as exc:
134
+ raise HTTPException(status_code=400, detail="path exists and is not a directory") from exc
135
+ except PermissionError as exc:
136
+ raise HTTPException(status_code=403, detail="Directory is not writable") from exc
137
+ except OSError as exc:
138
+ raise HTTPException(status_code=400, detail="Unable to create directory") from exc
139
+ if not resolved.is_dir() or not _is_within(resolved, roots):
140
+ raise HTTPException(status_code=403, detail="Directory is outside the allowed browse roots")
141
+ return resolved
142
+
143
+
144
+ def _windows_drive_roots() -> list[Path]:
145
+ """Existing drive letters, so Windows pickers can reach any disk without per-drive config."""
146
+ if os.name != "nt":
147
+ return []
148
+ drives: list[Path] = []
149
+ for letter in "CDEFGHIJKLMNOPQRSTUVWXYZ":
150
+ candidate = Path(f"{letter}:\\")
151
+ if candidate.exists():
152
+ drives.append(candidate)
153
+ return drives
154
+
155
+
156
+ def build_workspace_info(startup_cwd: str, configured_roots: list[str]) -> WorkspaceInfo:
157
+ home = Path.home().resolve()
158
+ cwd = Path(startup_cwd).resolve()
159
+ roots: list[Path] = []
160
+ candidates = [home, cwd]
161
+ candidates.extend(Path(value).expanduser() for value in configured_roots)
162
+ candidates.extend(_windows_drive_roots())
163
+ for candidate in candidates:
164
+ try:
165
+ resolved = candidate.resolve(strict=True)
166
+ except OSError:
167
+ continue
168
+ if not resolved.is_dir() or resolved in roots:
169
+ continue
170
+ roots.append(resolved)
171
+ return WorkspaceInfo(
172
+ startup_cwd=str(cwd),
173
+ runtime=GatewayRuntimeInfo(
174
+ gateway_version=__version__,
175
+ gateway_path=str(Path(__file__).resolve().parents[1]),
176
+ python_version=platform.python_version(),
177
+ python_executable=sys.executable,
178
+ python_prefix=sys.prefix,
179
+ environment_kind=(
180
+ "conda" if (Path(sys.prefix) / "conda-meta").is_dir()
181
+ else "venv" if sys.prefix != sys.base_prefix else "system"
182
+ ),
183
+ platform=f"{platform.system()} {platform.machine()}",
184
+ ),
185
+ home=str(home),
186
+ browse_roots=[str(root) for root in roots],
187
+ documents_dir=str(_recommended_documents_dir(home)),
188
+ )
189
+
190
+
191
+ def _recommended_documents_dir(home: Path) -> Path:
192
+ """Return a visible, cross-platform document root without creating it."""
193
+ if os.name == "nt":
194
+ configured = os.environ.get("USERPROFILE")
195
+ base = Path(configured).expanduser() if configured else home
196
+ return base / "Documents" / "CrabCode"
197
+ return home / "Documents" / "CrabCode"
198
+
199
+
200
+ def _workspace_roots(request: Request) -> tuple[Path, ...]:
201
+ info: WorkspaceInfo = request.app.state.workspace_info
202
+ return tuple(Path(value) for value in info.browse_roots)
203
+
204
+
205
+ @router.get("/info", response_model=WorkspaceInfo)
206
+ async def workspace_info(request: Request) -> WorkspaceInfo:
207
+ return request.app.state.workspace_info
208
+
209
+
210
+ @router.get("/directories", response_model=WorkspaceDirectoryListing)
211
+ async def list_directories(
212
+ request: Request,
213
+ path: str | None = None,
214
+ include_hidden: bool = False,
215
+ include_files: bool = False,
216
+ ) -> WorkspaceDirectoryListing:
217
+ info: WorkspaceInfo = request.app.state.workspace_info
218
+ roots = _workspace_roots(request)
219
+ current = _resolve_directory(path or info.home, roots)
220
+
221
+ directories: list[WorkspaceDirectoryEntry] = []
222
+ files: list[WorkspaceFileEntry] = []
223
+ try:
224
+ children = sorted(current.iterdir(), key=lambda item: item.name.casefold())
225
+ except PermissionError as exc:
226
+ raise HTTPException(status_code=403, detail="Directory is not readable") from exc
227
+ except OSError as exc:
228
+ raise HTTPException(status_code=400, detail="Unable to list directory") from exc
229
+
230
+ for child in children:
231
+ hidden = child.name.startswith(".")
232
+ if hidden and not include_hidden:
233
+ continue
234
+ try:
235
+ resolved = child.resolve(strict=True)
236
+ if not _is_within(resolved, roots):
237
+ continue
238
+ except (OSError, RuntimeError):
239
+ continue
240
+ if resolved.is_dir():
241
+ directories.append(
242
+ WorkspaceDirectoryEntry(
243
+ name=child.name,
244
+ path=str(child.absolute()),
245
+ hidden=hidden,
246
+ is_symlink=child.is_symlink(),
247
+ )
248
+ )
249
+ elif include_files and resolved.is_file():
250
+ try:
251
+ size = resolved.stat().st_size
252
+ except OSError:
253
+ size = 0
254
+ files.append(
255
+ WorkspaceFileEntry(
256
+ name=child.name,
257
+ path=str(child.absolute()),
258
+ size=size,
259
+ hidden=hidden,
260
+ is_symlink=child.is_symlink(),
261
+ )
262
+ )
263
+
264
+ parent = current.parent
265
+ parent_path = str(parent) if parent != current and _is_within(parent, roots) else None
266
+ return WorkspaceDirectoryListing(
267
+ path=str(current),
268
+ parent=parent_path,
269
+ directories=directories,
270
+ files=files,
271
+ )
272
+
273
+
274
+ @router.get("/file", response_class=FileResponse)
275
+ async def workspace_file(path: str, request: Request) -> FileResponse:
276
+ candidate = _resolve_preview_file(path, _workspace_roots(request))
277
+ media_type, max_bytes = _preview_media_type(candidate)
278
+ try:
279
+ size = candidate.stat().st_size
280
+ except OSError as exc:
281
+ raise HTTPException(status_code=400, detail="Unable to inspect file") from exc
282
+ if size > max_bytes:
283
+ limit = "10 MiB" if max_bytes == MAX_IMAGE_PREVIEW_BYTES else "2 MiB"
284
+ raise HTTPException(status_code=413, detail=f"File exceeds the {limit} preview limit")
285
+ return FileResponse(candidate, media_type=media_type)
286
+
287
+
288
+ @router.post("/directories/create", response_model=WorkspaceDirectoryEntry)
289
+ async def create_directory(
290
+ req: WorkspaceDirectoryCreateRequest,
291
+ request: Request,
292
+ ) -> WorkspaceDirectoryEntry:
293
+ created = _create_directory(req.path, _workspace_roots(request))
294
+ return WorkspaceDirectoryEntry(
295
+ name=created.name,
296
+ path=str(created),
297
+ hidden=created.name.startswith("."),
298
+ is_symlink=created.is_symlink(),
299
+ )
@@ -0,0 +1,63 @@
1
+ """Bounded, race-resistant reads for Gateway-owned output files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from pathlib import Path
8
+
9
+
10
+ _MAX_TAIL_BYTES = 8 * 1024 * 1024
11
+
12
+
13
+ def read_regular_file_tail(
14
+ path: Path,
15
+ *,
16
+ max_lines: int,
17
+ max_bytes: int = _MAX_TAIL_BYTES,
18
+ ) -> tuple[list[str], bool]:
19
+ """Read a bounded tail from one non-linked regular file.
20
+
21
+ ``O_NOFOLLOW`` closes the final-component symlink race on platforms that
22
+ support it. The descriptor is then checked independently so a missing
23
+ flag or a path replacement cannot turn a task-output endpoint into an
24
+ arbitrary file reader.
25
+ """
26
+ target = Path(path)
27
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
28
+ fd = os.open(target, flags)
29
+ try:
30
+ descriptor_stat = os.fstat(fd)
31
+ if not stat.S_ISREG(descriptor_stat.st_mode) or descriptor_stat.st_nlink != 1:
32
+ raise OSError("output is not a dedicated regular file")
33
+ path_stat = os.stat(target, follow_symlinks=False)
34
+ if (
35
+ not stat.S_ISREG(path_stat.st_mode)
36
+ or path_stat.st_nlink != 1
37
+ or (path_stat.st_dev, path_stat.st_ino)
38
+ != (descriptor_stat.st_dev, descriptor_stat.st_ino)
39
+ ):
40
+ raise OSError("output file changed while opening")
41
+
42
+ size = descriptor_stat.st_size
43
+ byte_limit = max(1, int(max_bytes))
44
+ start = max(0, size - byte_limit)
45
+ os.lseek(fd, start, os.SEEK_SET)
46
+ remaining = size - start
47
+ chunks: list[bytes] = []
48
+ while remaining > 0:
49
+ chunk = os.read(fd, min(64 * 1024, remaining))
50
+ if not chunk:
51
+ break
52
+ chunks.append(chunk)
53
+ remaining -= len(chunk)
54
+ decoded = b"".join(chunks).decode("utf-8", errors="replace")
55
+ lines = decoded.splitlines()
56
+ line_limit = max(1, int(max_lines))
57
+ truncated = start > 0 or len(lines) > line_limit
58
+ return lines[-line_limit:], truncated
59
+ finally:
60
+ os.close(fd)
61
+
62
+
63
+ __all__ = ["read_regular_file_tail"]