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,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"]
|