usta 0.2.0__py3-none-any.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 (156) hide show
  1. usta/__init__.py +0 -0
  2. usta/agent.py +319 -0
  3. usta/api.py +834 -0
  4. usta/apis.py +103 -0
  5. usta/attachments.py +60 -0
  6. usta/audit.py +47 -0
  7. usta/auth.py +55 -0
  8. usta/bench.py +904 -0
  9. usta/bench_tasks/add-feature/check/test_todo.py +37 -0
  10. usta/bench_tasks/add-feature/repo/test_todo.py +11 -0
  11. usta/bench_tasks/add-feature/repo/todo.py +10 -0
  12. usta/bench_tasks/add-feature/task.json +10 -0
  13. usta/bench_tasks/cli-args/check/test_cli.py +44 -0
  14. usta/bench_tasks/cli-args/repo/cli.py +2 -0
  15. usta/bench_tasks/cli-args/task.json +10 -0
  16. usta/bench_tasks/cross-module-bug/check/test_cart.py +19 -0
  17. usta/bench_tasks/cross-module-bug/check/test_pricing.py +12 -0
  18. usta/bench_tasks/cross-module-bug/repo/cart.py +7 -0
  19. usta/bench_tasks/cross-module-bug/repo/pricing.py +5 -0
  20. usta/bench_tasks/cross-module-bug/repo/test_cart.py +8 -0
  21. usta/bench_tasks/cross-module-bug/repo/test_pricing.py +12 -0
  22. usta/bench_tasks/cross-module-bug/task.json +10 -0
  23. usta/bench_tasks/csv-quotes/check/test_parse.py +22 -0
  24. usta/bench_tasks/csv-quotes/repo/parse.py +2 -0
  25. usta/bench_tasks/csv-quotes/task.json +10 -0
  26. usta/bench_tasks/deep-merge/check/test_config.py +31 -0
  27. usta/bench_tasks/deep-merge/repo/config.py +2 -0
  28. usta/bench_tasks/deep-merge/task.json +10 -0
  29. usta/bench_tasks/expr-eval/check/test_calc.py +37 -0
  30. usta/bench_tasks/expr-eval/repo/calc.py +2 -0
  31. usta/bench_tasks/expr-eval/task.json +10 -0
  32. usta/bench_tasks/implement-slugify/check/test_slug.py +19 -0
  33. usta/bench_tasks/implement-slugify/repo/slug.py +2 -0
  34. usta/bench_tasks/implement-slugify/task.json +10 -0
  35. usta/bench_tasks/interval-merge/check/test_intervals.py +30 -0
  36. usta/bench_tasks/interval-merge/repo/intervals.py +2 -0
  37. usta/bench_tasks/interval-merge/task.json +10 -0
  38. usta/bench_tasks/leap-dates/check/test_dates.py +34 -0
  39. usta/bench_tasks/leap-dates/repo/dates.py +15 -0
  40. usta/bench_tasks/leap-dates/repo/test_dates.py +9 -0
  41. usta/bench_tasks/leap-dates/task.json +10 -0
  42. usta/bench_tasks/lru-cache/check/test_lru.py +50 -0
  43. usta/bench_tasks/lru-cache/repo/lru.py +3 -0
  44. usta/bench_tasks/lru-cache/task.json +10 -0
  45. usta/bench_tasks/money-rounding/check/test_money.py +27 -0
  46. usta/bench_tasks/money-rounding/repo/money.py +7 -0
  47. usta/bench_tasks/money-rounding/repo/test_money.py +8 -0
  48. usta/bench_tasks/money-rounding/task.json +10 -0
  49. usta/bench_tasks/multi-file-rename/check/test_users.py +21 -0
  50. usta/bench_tasks/multi-file-rename/repo/api.py +5 -0
  51. usta/bench_tasks/multi-file-rename/repo/greeting.py +5 -0
  52. usta/bench_tasks/multi-file-rename/repo/report.py +5 -0
  53. usta/bench_tasks/multi-file-rename/repo/test_users.py +8 -0
  54. usta/bench_tasks/multi-file-rename/repo/users.py +2 -0
  55. usta/bench_tasks/multi-file-rename/task.json +10 -0
  56. usta/bench_tasks/off-by-one/check/test_stats.py +21 -0
  57. usta/bench_tasks/off-by-one/repo/stats.py +8 -0
  58. usta/bench_tasks/off-by-one/repo/test_stats.py +8 -0
  59. usta/bench_tasks/off-by-one/task.json +10 -0
  60. usta/bench_tasks/text-wrap/check/test_wrap.py +28 -0
  61. usta/bench_tasks/text-wrap/repo/wrap.py +2 -0
  62. usta/bench_tasks/text-wrap/task.json +10 -0
  63. usta/bench_tasks/tiny-case-insensitive/check/test_find.py +17 -0
  64. usta/bench_tasks/tiny-case-insensitive/repo/find.py +3 -0
  65. usta/bench_tasks/tiny-case-insensitive/repo/test_find.py +8 -0
  66. usta/bench_tasks/tiny-case-insensitive/task.json +11 -0
  67. usta/bench_tasks/tiny-default-sep/check/test_words.py +17 -0
  68. usta/bench_tasks/tiny-default-sep/repo/test_words.py +8 -0
  69. usta/bench_tasks/tiny-default-sep/repo/words.py +3 -0
  70. usta/bench_tasks/tiny-default-sep/task.json +11 -0
  71. usta/bench_tasks/tiny-greeting/check/test_greet.py +14 -0
  72. usta/bench_tasks/tiny-greeting/repo/greet.py +3 -0
  73. usta/bench_tasks/tiny-greeting/repo/test_greet.py +8 -0
  74. usta/bench_tasks/tiny-greeting/task.json +11 -0
  75. usta/bench_tasks/tiny-is-even/check/test_parity.py +18 -0
  76. usta/bench_tasks/tiny-is-even/repo/parity.py +5 -0
  77. usta/bench_tasks/tiny-is-even/repo/test_parity.py +9 -0
  78. usta/bench_tasks/tiny-is-even/task.json +11 -0
  79. usta/bench_tasks/tiny-missing-return/check/test_nums.py +19 -0
  80. usta/bench_tasks/tiny-missing-return/repo/nums.py +5 -0
  81. usta/bench_tasks/tiny-missing-return/repo/test_nums.py +8 -0
  82. usta/bench_tasks/tiny-missing-return/task.json +11 -0
  83. usta/bench_tasks/tiny-strip-names/check/test_names.py +17 -0
  84. usta/bench_tasks/tiny-strip-names/repo/names.py +3 -0
  85. usta/bench_tasks/tiny-strip-names/repo/test_names.py +8 -0
  86. usta/bench_tasks/tiny-strip-names/task.json +11 -0
  87. usta/bench_tasks/tiny-swap-args/check/test_pct.py +17 -0
  88. usta/bench_tasks/tiny-swap-args/repo/pct.py +5 -0
  89. usta/bench_tasks/tiny-swap-args/repo/test_pct.py +8 -0
  90. usta/bench_tasks/tiny-swap-args/task.json +11 -0
  91. usta/bench_tasks/tiny-wrong-operator/check/test_avg.py +17 -0
  92. usta/bench_tasks/tiny-wrong-operator/repo/avg.py +5 -0
  93. usta/bench_tasks/tiny-wrong-operator/repo/test_avg.py +8 -0
  94. usta/bench_tasks/tiny-wrong-operator/task.json +11 -0
  95. usta/bench_tasks/word-count-fix/check/test_text.py +25 -0
  96. usta/bench_tasks/word-count-fix/repo/test_text.py +10 -0
  97. usta/bench_tasks/word-count-fix/repo/text.py +5 -0
  98. usta/bench_tasks/word-count-fix/task.json +10 -0
  99. usta/breaker.py +132 -0
  100. usta/capability.py +643 -0
  101. usta/cli.py +1358 -0
  102. usta/context.py +122 -0
  103. usta/fetch.py +406 -0
  104. usta/fetch_inner.py +112 -0
  105. usta/filetree.py +134 -0
  106. usta/jsonproto.py +155 -0
  107. usta/lab/__init__.py +4 -0
  108. usta/lab/env.py +127 -0
  109. usta/lab/evaluator.py +108 -0
  110. usta/lab/metrics.py +109 -0
  111. usta/lab/report.py +124 -0
  112. usta/lab/runner.py +242 -0
  113. usta/lab/scenario.py +266 -0
  114. usta/lab/scenarios/v1.jsonl +25 -0
  115. usta/lab/scripted.py +47 -0
  116. usta/mcp.py +344 -0
  117. usta/mcp_runtime.py +362 -0
  118. usta/mcp_tools.py +162 -0
  119. usta/mcpcatalog.py +39 -0
  120. usta/mcpconfig.py +306 -0
  121. usta/memory.py +218 -0
  122. usta/modelctl.py +124 -0
  123. usta/modelinfo.py +19 -0
  124. usta/models.py +170 -0
  125. usta/outline.py +191 -0
  126. usta/pdf_inner.py +59 -0
  127. usta/phases.py +267 -0
  128. usta/policy.py +104 -0
  129. usta/portal.py +107 -0
  130. usta/profile.py +177 -0
  131. usta/projectcfg.py +90 -0
  132. usta/projects.py +67 -0
  133. usta/proxy.py +302 -0
  134. usta/repl.py +590 -0
  135. usta/rescue.py +66 -0
  136. usta/sandbox.py +359 -0
  137. usta/sessions.py +120 -0
  138. usta/settings.py +93 -0
  139. usta/skills.py +79 -0
  140. usta/symbols.py +618 -0
  141. usta/tasks.py +633 -0
  142. usta/taxonomy.py +169 -0
  143. usta/textproto.py +149 -0
  144. usta/theme.py +104 -0
  145. usta/tools.py +369 -0
  146. usta/verify.py +233 -0
  147. usta/web/app.css +160 -0
  148. usta/web/app.js +1064 -0
  149. usta/web/index.html +197 -0
  150. usta/web/logic.js +175 -0
  151. usta/workspace.py +236 -0
  152. usta-0.2.0.dist-info/METADATA +211 -0
  153. usta-0.2.0.dist-info/RECORD +156 -0
  154. usta-0.2.0.dist-info/WHEEL +4 -0
  155. usta-0.2.0.dist-info/entry_points.txt +2 -0
  156. usta-0.2.0.dist-info/licenses/LICENSE +202 -0
usta/api.py ADDED
@@ -0,0 +1,834 @@
1
+ """HTTP API (FastAPI): çalışma alanları, görevler, SSE olay akışı, onaylar, diff, denetim."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import mimetypes
10
+ import os
11
+ import re
12
+ import secrets
13
+ from collections import deque
14
+ from pathlib import Path
15
+ from typing import Annotated, Literal
16
+
17
+ from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Query
18
+ from fastapi import Path as PathParam
19
+ from fastapi.responses import PlainTextResponse, Response, StreamingResponse
20
+ from pydantic import BaseModel, Field
21
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
22
+
23
+ from usta import apis, attachments, filetree, mcp_runtime, mcpcatalog, mcpconfig, memory, skills
24
+ from usta import settings as usta_settings
25
+ from usta.audit import AuditLog
26
+ from usta.auth import LoginCodes
27
+ from usta.mcp import McpClient, McpError
28
+ from usta.modelctl import ModelSwitchError
29
+ from usta.policy import PathEscapeError, resolve_path
30
+ from usta.tasks import PlanNotFound, RevertRefused, TaskFinished, TaskManager, WorkspaceBusy
31
+
32
+ ID_PATTERN = r"^[a-z0-9][a-z0-9_-]{0,39}$"
33
+ MAX_FILE = 1_000_000
34
+ KEEPALIVE = 15.0
35
+
36
+ WEB_DIR = Path(__file__).parent / "web"
37
+ UI_FILES = {
38
+ "/": ("index.html", "text/html; charset=utf-8"),
39
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
40
+ "/logic.js": ("logic.js", "text/javascript; charset=utf-8"),
41
+ "/app.css": ("app.css", "text/css; charset=utf-8"),
42
+ }
43
+ UI_HEADERS = {
44
+ "Content-Security-Policy": (
45
+ "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; "
46
+ "img-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
47
+ ),
48
+ "X-Content-Type-Options": "nosniff",
49
+ "Referrer-Policy": "no-referrer",
50
+ "Cache-Control": "no-store",
51
+ }
52
+
53
+ WorkspaceId = Annotated[str, PathParam(pattern=ID_PATTERN)]
54
+ ServerName = Annotated[str, PathParam(pattern=r"^[a-z0-9][a-z0-9_-]{0,29}$")]
55
+
56
+
57
+ class WorkspaceIn(BaseModel):
58
+ name: str = Field(pattern=ID_PATTERN)
59
+
60
+
61
+ class AttachIn(BaseModel):
62
+ name: str = Field(min_length=1, max_length=200)
63
+ content: str = Field(max_length=attachments.MAX_FILE * 2)
64
+
65
+
66
+ class TaskIn(BaseModel):
67
+ prompt: str = Field(min_length=1, max_length=20_000)
68
+ mode: Literal["manual", "auto", "plan"] = "manual"
69
+ guard: bool | None = None # None: sunucu varsayılanı
70
+ files: list[AttachIn] = Field(default_factory=list, max_length=attachments.MAX_FILES)
71
+ apply_plan: str | None = Field(
72
+ default=None, pattern=r"^[a-f0-9]{12}$"
73
+ ) # bitmiş plan görevinin kimliği
74
+ phased: bool = (
75
+ False # planı PLAN.md aşamalarına bölüp her aşamayı taze bağlamla ayrı görev koştur
76
+ )
77
+
78
+
79
+ class MessageIn(BaseModel):
80
+ text: str = Field(min_length=1, max_length=20_000)
81
+
82
+
83
+ class ModelIn(BaseModel):
84
+ model: str = Field(min_length=1, max_length=200)
85
+ api: str | None = Field(default=None, max_length=30)
86
+
87
+
88
+ class ApiIn(BaseModel):
89
+ name: str = Field(min_length=1, max_length=30)
90
+ endpoint: str = Field(min_length=1, max_length=500)
91
+ key: str | None = Field(default=None, max_length=8192)
92
+
93
+
94
+ class KeyIn(BaseModel):
95
+ key: str | None = Field(default=None, max_length=8192)
96
+
97
+
98
+ class ImportFile(BaseModel):
99
+ path: str = Field(min_length=1, max_length=500)
100
+ b64: str = Field(max_length=filetree.MAX_IMPORT_FILE * 2)
101
+
102
+
103
+ class ImportIn(BaseModel):
104
+ files: list[ImportFile] = Field(max_length=filetree.MAX_IMPORT_FILES)
105
+ overwrite: bool = False
106
+
107
+
108
+ class FsOp(BaseModel):
109
+ op: Literal["create_file", "create_dir", "rename", "delete"]
110
+ path: str = Field(min_length=1, max_length=500)
111
+ to: str | None = Field(default=None, max_length=500)
112
+
113
+
114
+ class SkillIn(BaseModel):
115
+ content: str = Field(min_length=1, max_length=skills.MAX_SIZE * 2)
116
+
117
+
118
+ PREVIEW_MAX = 5_000_000
119
+ PREVIEW_HEADERS = {
120
+ # Ajanın yazdığı içerik güvenilmezdir: opak kaynakta (sandbox, allow-same-origin YOK) çalışır,
121
+ # portalın jetonuna/oturumuna erişemez ve ağa çıkamaz.
122
+ "Content-Security-Policy": (
123
+ "sandbox allow-scripts; default-src 'self' data: blob:; "
124
+ "script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
125
+ "connect-src 'none'; form-action 'none'; frame-ancestors 'self'"
126
+ ),
127
+ "X-Content-Type-Options": "nosniff",
128
+ "Referrer-Policy": "no-referrer",
129
+ "Cache-Control": "no-store",
130
+ }
131
+
132
+
133
+ class SettingsIn(BaseModel):
134
+ """Portaldan yalnız görünüm ve mod değiştirilebilir; uç nokta/model/sırlar buradan değişmez."""
135
+
136
+ theme: Literal["beyaz", "gri", "siyah"] | None = None
137
+ mode: Literal["manual", "auto", "plan"] | None = None
138
+ guard: bool | None = None
139
+ zoom: Literal["auto", "0.9", "1", "1.1", "1.25", "1.5", "1.75"] | None = None
140
+
141
+
142
+ class CodeIn(BaseModel):
143
+ code: str = Field(max_length=200)
144
+
145
+
146
+ class SecretIn(BaseModel):
147
+ value: str = Field(min_length=1, max_length=8192)
148
+
149
+
150
+ class EnabledIn(BaseModel):
151
+ enabled: bool
152
+
153
+
154
+ class FileIn(BaseModel):
155
+ content: str = Field(max_length=MAX_FILE)
156
+
157
+
158
+ class ApprovalIn(BaseModel):
159
+ decision: Literal["allow", "deny"]
160
+
161
+
162
+ def load_or_create_token(home: Path) -> str:
163
+ """Yönetici jetonu home/token dosyasında (0600) tutulur; repoya girmez."""
164
+ path = Path(home) / "token"
165
+ if path.exists():
166
+ return path.read_text().strip()
167
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
168
+ token = secrets.token_urlsafe(32)
169
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
170
+ with os.fdopen(fd, "w") as f:
171
+ f.write(token + "\n")
172
+ return token
173
+
174
+
175
+ MAX_BODY = 10_000_000 # varsayılan istek gövdesi sınırı (kimlik doğrulamadan ÖNCE uygulanır)
176
+ MAX_BODY_IMPORT = 32_000_000
177
+ MAX_BODY_AUTH = 4_096
178
+
179
+
180
+ def _body_limit(path: str) -> int:
181
+ if path == "/auth/exchange":
182
+ return MAX_BODY_AUTH
183
+ return MAX_BODY_IMPORT if re.fullmatch(r"/workspaces/[^/]+/import", path) else MAX_BODY
184
+
185
+
186
+ class BodyLimitMiddleware:
187
+ """İstek gövdesini boyuta göre keser (yetki denetimi gövdeyi okumadan önce çalışsın diye ASGI düzeyinde)."""
188
+
189
+ def __init__(self, app):
190
+ self.app = app
191
+
192
+ async def __call__(self, scope, receive, send):
193
+ if scope["type"] != "http":
194
+ await self.app(scope, receive, send)
195
+ return
196
+ limit = _body_limit(scope.get("path", ""))
197
+ declared = dict(scope.get("headers") or []).get(b"content-length", b"")
198
+ responded = False
199
+
200
+ async def too_large():
201
+ nonlocal responded
202
+ if responded:
203
+ return
204
+ responded = True
205
+ body = b'{"detail":"istek \xc3\xa7ok b\xc3\xbcy\xc3\xbck"}'
206
+ await send(
207
+ {
208
+ "type": "http.response.start",
209
+ "status": 413,
210
+ "headers": [
211
+ (b"content-type", b"application/json"),
212
+ (b"content-length", str(len(body)).encode()),
213
+ (b"connection", b"close"),
214
+ ],
215
+ }
216
+ )
217
+ await send({"type": "http.response.body", "body": body})
218
+
219
+ if declared.isdigit() and int(declared) > limit:
220
+ await too_large()
221
+ return
222
+ received = 0
223
+
224
+ async def limited_receive():
225
+ nonlocal received
226
+ if responded:
227
+ return {"type": "http.disconnect"}
228
+ msg = await receive()
229
+ if msg["type"] == "http.request":
230
+ received += len(msg.get("body", b""))
231
+ if received > limit:
232
+ await too_large()
233
+ return {"type": "http.disconnect"}
234
+ return msg
235
+
236
+ async def guarded_send(msg):
237
+ if not responded:
238
+ await send(msg)
239
+
240
+ await self.app(scope, limited_receive, guarded_send)
241
+
242
+
243
+ def create_app(
244
+ manager: TaskManager,
245
+ home: Path,
246
+ token: str,
247
+ login: LoginCodes | None = None,
248
+ workspace: str | None = None,
249
+ trusted_hosts: list[str] | None = None,
250
+ ) -> FastAPI:
251
+ home = Path(home)
252
+
253
+ def auth(authorization: Annotated[str | None, Header()] = None) -> None:
254
+ given = authorization.removeprefix("Bearer ") if authorization else ""
255
+ if not given or not hmac.compare_digest(given.encode(), token.encode()):
256
+ raise HTTPException(401, "yetkisiz", headers={"WWW-Authenticate": "Bearer"})
257
+
258
+ app = FastAPI(title="Usta", docs_url=None, redoc_url=None, openapi_url=None)
259
+ app.add_middleware(BodyLimitMiddleware)
260
+ if trusted_hosts:
261
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=trusted_hosts)
262
+ api = APIRouter(dependencies=[Depends(auth)])
263
+
264
+ def ws_root(workspace: str) -> Path:
265
+ root = home / "workspaces" / workspace
266
+ if not root.is_dir():
267
+ raise HTTPException(404, "çalışma alanı yok")
268
+ return root
269
+
270
+ def get_task(task_id: str):
271
+ try:
272
+ return manager.get(task_id)
273
+ except KeyError:
274
+ raise HTTPException(404, "görev yok") from None
275
+
276
+ @api.post("/workspaces", status_code=201)
277
+ def create_workspace(body: WorkspaceIn) -> dict:
278
+ root = home / "workspaces" / body.name
279
+ try:
280
+ root.mkdir(parents=True)
281
+ except FileExistsError:
282
+ raise HTTPException(409, "çalışma alanı zaten var") from None
283
+ return {"id": body.name}
284
+
285
+ @api.get("/workspaces")
286
+ def list_workspaces() -> list[dict]:
287
+ base = home / "workspaces"
288
+ return (
289
+ [{"id": p.name} for p in sorted(base.iterdir()) if p.is_dir()] if base.is_dir() else []
290
+ )
291
+
292
+ def preview_key(workspace: str) -> str:
293
+ return hmac.new(
294
+ token.encode(), f"preview:{workspace}".encode(), hashlib.sha256
295
+ ).hexdigest()[:32]
296
+
297
+ @api.get("/workspaces/{workspace}/preview")
298
+ def preview_info(workspace: WorkspaceId) -> dict:
299
+ ws_root(workspace)
300
+ return {"base": f"/preview/{workspace}/{preview_key(workspace)}/"}
301
+
302
+ @api.get("/workspaces/{workspace}/tree")
303
+ def tree(workspace: WorkspaceId, path: str = "") -> dict:
304
+ root = ws_root(workspace)
305
+ try:
306
+ full = resolve_path(root, path or ".")
307
+ except PathEscapeError:
308
+ raise HTTPException(400, "geçersiz yol") from None
309
+ if not full.is_dir():
310
+ raise HTTPException(404, "klasör yok")
311
+ try:
312
+ return {"path": path, "entries": filetree.list_dir(full)}
313
+ except OSError:
314
+ raise HTTPException(400, "klasör okunamadı") from None
315
+
316
+ @api.post("/workspaces/{workspace}/import")
317
+ def import_files(workspace: WorkspaceId, body: ImportIn) -> dict:
318
+ root = idle_ws(workspace)
319
+ try:
320
+ decoded = filetree.decode_files([(f.path, f.b64) for f in body.files])
321
+ except filetree.ImportError_ as e:
322
+ raise HTTPException(400, str(e)) from None
323
+ written, skipped = [], []
324
+ for rel, data in decoded:
325
+ try:
326
+ full = resolve_path(root, rel)
327
+ except PathEscapeError:
328
+ raise HTTPException(400, f"geçersiz yol: {rel}") from None
329
+ if full.exists() and not body.overwrite:
330
+ skipped.append(rel)
331
+ continue
332
+ try:
333
+ full.parent.mkdir(parents=True, exist_ok=True)
334
+ full.write_bytes(data)
335
+ except OSError:
336
+ raise HTTPException(400, f"yazılamadı: {rel}") from None
337
+ written.append(rel)
338
+ return {"written": written, "skipped": skipped}
339
+
340
+ @api.post("/workspaces/{workspace}/fs")
341
+ def fs_op(workspace: WorkspaceId, body: FsOp) -> dict:
342
+ root = idle_ws(workspace)
343
+ try:
344
+ if body.op == "create_file":
345
+ filetree.create(root, body.path, is_dir=False)
346
+ elif body.op == "create_dir":
347
+ filetree.create(root, body.path, is_dir=True)
348
+ elif body.op == "rename":
349
+ if not body.to:
350
+ raise HTTPException(400, "yeni ad gerekli")
351
+ filetree.rename(root, body.path, body.to)
352
+ else:
353
+ filetree.delete(root, body.path)
354
+ except filetree.FsError as e:
355
+ raise HTTPException(e.status, str(e)) from None
356
+ with contextlib.suppress(OSError):
357
+ AuditLog(home / "audit" / f"{workspace}.jsonl").record(
358
+ "fs_op", op=body.op, path=body.path, to=body.to
359
+ )
360
+ return {"ok": True}
361
+
362
+ @api.get("/skills")
363
+ def list_skills() -> list[dict]:
364
+ return skills.list_skills(home)
365
+
366
+ @api.put("/skills/{name}")
367
+ def put_skill(name: str, body: SkillIn) -> dict:
368
+ try:
369
+ skills.put(home, name, body.content)
370
+ except skills.SkillError as e:
371
+ raise HTTPException(400, str(e)) from None
372
+ return {"name": name}
373
+
374
+ @api.delete("/skills/{name}")
375
+ def delete_skill(name: str) -> dict:
376
+ try:
377
+ skills.delete(home, name)
378
+ except skills.SkillError as e:
379
+ raise HTTPException(404, str(e)) from None
380
+ return {"removed": name}
381
+
382
+ @api.get("/workspaces/{workspace}/files")
383
+ def read_file(workspace: WorkspaceId, path: str) -> dict:
384
+ root = ws_root(workspace)
385
+ try:
386
+ full = resolve_path(root, path)
387
+ except PathEscapeError:
388
+ raise HTTPException(400, "geçersiz yol") from None
389
+ if not full.exists():
390
+ raise HTTPException(404, "dosya yok")
391
+ if not full.is_file():
392
+ raise HTTPException(400, "dosya değil")
393
+ if full.stat().st_size > MAX_FILE:
394
+ raise HTTPException(413, "dosya çok büyük")
395
+ raw = full.read_bytes()
396
+ if b"\x00" in raw[:8192]:
397
+ raise HTTPException(415, "ikili dosya gösterilemez")
398
+ return {"path": path, "content": raw.decode(errors="replace")}
399
+
400
+ @api.put("/workspaces/{workspace}/files")
401
+ def write_file(workspace: WorkspaceId, path: str, body: FileIn) -> dict:
402
+ root = ws_root(workspace)
403
+ try:
404
+ full = resolve_path(root, path)
405
+ except PathEscapeError:
406
+ raise HTTPException(400, "geçersiz yol") from None
407
+ if manager.is_busy(workspace):
408
+ raise HTTPException(409, "çalışma alanında görev sürüyor")
409
+ try:
410
+ full.parent.mkdir(parents=True, exist_ok=True)
411
+ full.write_text(body.content)
412
+ except OSError:
413
+ raise HTTPException(400, "yazılamadı") from None
414
+ return {"path": path, "bytes": len(body.content.encode())}
415
+
416
+ def idle_ws(workspace: str) -> Path:
417
+ root = ws_root(workspace)
418
+ if manager.is_busy(workspace):
419
+ raise HTTPException(409, "çalışma alanında görev sürüyor")
420
+ return root
421
+
422
+ @api.get("/workspaces/{workspace}/memory")
423
+ def get_memory(workspace: WorkspaceId) -> dict:
424
+ root = ws_root(workspace)
425
+ return {"notes": memory.list_notes(root), "history": memory.recent_history(root, 50)}
426
+
427
+ @api.delete("/workspaces/{workspace}/memory/notes/{index}")
428
+ def delete_note(workspace: WorkspaceId, index: Annotated[int, PathParam(ge=0)]) -> dict:
429
+ root = idle_ws(workspace)
430
+ try:
431
+ memory.delete_note(root, index)
432
+ except IndexError:
433
+ raise HTTPException(404, "not yok") from None
434
+ except (OSError, ValueError):
435
+ raise HTTPException(400, "hafıza yazılamadı") from None
436
+ return {"deleted": index}
437
+
438
+ @api.delete("/workspaces/{workspace}/memory/history")
439
+ def clear_memory_history(workspace: WorkspaceId) -> dict:
440
+ root = idle_ws(workspace)
441
+ try:
442
+ memory.clear_history(root)
443
+ except (OSError, ValueError):
444
+ raise HTTPException(400, "hafıza yazılamadı") from None
445
+ return {"cleared": True}
446
+
447
+ @api.get("/settings")
448
+ def get_settings() -> dict:
449
+ cur = usta_settings.load(home)
450
+ return {
451
+ "theme": cur.theme,
452
+ "mode": cur.mode,
453
+ "guard": cur.guard,
454
+ "zoom": cur.zoom,
455
+ "workspace": workspace,
456
+ }
457
+
458
+ @api.put("/settings")
459
+ def put_settings(body: SettingsIn) -> dict:
460
+ import dataclasses
461
+
462
+ cur = usta_settings.load(home)
463
+ changes = {k: v for k, v in body.model_dump().items() if v is not None}
464
+ new = dataclasses.replace(cur, **changes)
465
+ try:
466
+ usta_settings.save(home, new)
467
+ except OSError:
468
+ raise HTTPException(500, "ayar kaydedilemedi") from None
469
+ return {"theme": new.theme, "mode": new.mode, "guard": new.guard, "zoom": new.zoom}
470
+
471
+ # --- MCP: yalnız katalogdan ekleme; serbest komut yok (bkz. mcpcatalog) -------------------
472
+ def mcp_servers() -> dict[str, mcpconfig.Server]:
473
+ try:
474
+ return mcpconfig.load(home)
475
+ except mcpconfig.McpConfigError as e:
476
+ raise HTTPException(500, f"MCP yapılandırması: {e}") from None
477
+
478
+ def mcp_server(name: str) -> mcpconfig.Server:
479
+ server = mcp_servers().get(name)
480
+ if server is None:
481
+ raise HTTPException(404, "sunucu yok")
482
+ return server
483
+
484
+ @api.get("/mcp")
485
+ def mcp_list() -> dict:
486
+ servers = mcp_servers()
487
+ out = []
488
+ for s in servers.values():
489
+ have = mcpconfig.resolve_env(home, s, os.environ)
490
+ out.append(
491
+ {
492
+ "name": s.name,
493
+ "enabled": s.enabled,
494
+ "approve": s.approve,
495
+ "command": list(s.command),
496
+ "env": [{"name": k, "set": k in have} for k in s.env],
497
+ "isolation": s.isolation,
498
+ "image": s.image,
499
+ "allow_hosts": list(s.allow_hosts),
500
+ "pinned": bool(s.pin_image and s.pin_cache),
501
+ }
502
+ )
503
+ catalog = [
504
+ {
505
+ "id": e.id,
506
+ "title": e.title,
507
+ "description": e.description,
508
+ "homepage": e.homepage,
509
+ "command": list(e.command),
510
+ "env": list(e.env),
511
+ "image": e.image,
512
+ "allow_hosts": list(e.allow_hosts),
513
+ "added": e.id in servers,
514
+ }
515
+ for e in mcpcatalog.CATALOG
516
+ ]
517
+ return {"servers": out, "catalog": catalog}
518
+
519
+ @api.post("/mcp/catalog/{entry_id}", status_code=201)
520
+ def mcp_add_from_catalog(entry_id: str) -> dict:
521
+ entry = mcpcatalog.get(entry_id)
522
+ if entry is None:
523
+ raise HTTPException(404, "katalogda yok")
524
+ try:
525
+ mcpconfig.add_server(
526
+ home,
527
+ entry.id,
528
+ list(entry.command),
529
+ list(entry.env),
530
+ "ask",
531
+ isolation="container", # katalog sunucuları her zaman yalıtılmış çalışır
532
+ image=entry.image,
533
+ allow_hosts=list(entry.allow_hosts),
534
+ )
535
+ except mcpconfig.McpConfigError as e:
536
+ raise HTTPException(409 if "zaten" in str(e) else 400, str(e)) from None
537
+ return {"name": entry.id}
538
+
539
+ @api.put("/mcp/{name}/secrets/{key}")
540
+ def mcp_set_secret(name: ServerName, key: str, body: SecretIn) -> dict:
541
+ mcp_server(name)
542
+ try:
543
+ mcpconfig.set_secret(home, name, key, body.value)
544
+ except mcpconfig.McpConfigError as e:
545
+ raise HTTPException(404 if "tanımlı değil" in str(e) else 400, str(e)) from None
546
+ return {"set": True}
547
+
548
+ @api.post("/mcp/{name}/enabled")
549
+ def mcp_enable(name: ServerName, body: EnabledIn) -> dict:
550
+ mcp_server(name)
551
+ mcpconfig.set_enabled(home, name, body.enabled)
552
+ return {"enabled": body.enabled}
553
+
554
+ @api.delete("/mcp/{name}")
555
+ def mcp_remove(name: ServerName) -> dict:
556
+ mcp_server(name)
557
+ mcpconfig.remove_server(home, name)
558
+ return {"removed": name}
559
+
560
+ @api.post("/mcp/{name}/test")
561
+ def mcp_test(name: ServerName) -> dict:
562
+ server = mcp_server(name)
563
+ env = mcpconfig.resolve_env(home, server, os.environ)
564
+ try:
565
+ if server.isolation == "host": # yalnız CLI'dan açıkça eklenmiş uyumluluk modu
566
+ with McpClient(list(server.command), env, timeout=90) as client:
567
+ tools = client.list_tools()
568
+ else:
569
+ handle = mcp_runtime.start(server, home=home, env=env, workspace=None, timeout=90)
570
+ try:
571
+ tools = handle.client.list_tools()
572
+ finally:
573
+ handle.close()
574
+ except McpError as e:
575
+ raise HTTPException(502, str(e)) from None
576
+ return {"tools": [{"name": t.name, "description": t.description[:200]} for t in tools]}
577
+
578
+ @api.post("/mcp/{name}/prepare")
579
+ def mcp_prepare(name: ServerName) -> dict:
580
+ server = mcp_server(name)
581
+ if server.isolation != "container":
582
+ raise HTTPException(400, "yalnız konteyner izolasyonlu sunucular hazırlanır")
583
+ try:
584
+ iid, digest = mcp_runtime.prepare(
585
+ server, home=home, env=mcpconfig.resolve_env(home, server, os.environ)
586
+ )
587
+ mcpconfig.set_pin(home, name, iid, digest)
588
+ except McpError as e:
589
+ raise HTTPException(502, str(e)) from None
590
+ return {"pinned": True}
591
+
592
+ @api.post("/workspaces/{workspace}/tasks", status_code=202)
593
+ def create_task(workspace: WorkspaceId, body: TaskIn) -> dict:
594
+ root = ws_root(workspace)
595
+ try:
596
+ files = [(f.name, f.content) for f in body.files]
597
+ prompt = body.prompt if body.apply_plan else skills.expand(home, body.prompt)
598
+ return manager.start(
599
+ workspace, root, prompt, body.mode, body.guard, files, body.apply_plan, body.phased
600
+ ).public()
601
+ except PlanNotFound:
602
+ raise HTTPException(
603
+ 404, "uygulanacak plan yok (plan modunda başarıyla bitmiş olmalı)"
604
+ ) from None
605
+ except ValueError as e:
606
+ raise HTTPException(400, str(e)) from None
607
+ except attachments.AttachmentError as e:
608
+ raise HTTPException(400, str(e)) from None
609
+ except WorkspaceBusy:
610
+ raise HTTPException(409, "çalışma alanında görev sürüyor") from None
611
+
612
+ @api.get("/workspaces/{workspace}/sessions")
613
+ def list_sessions(workspace: WorkspaceId) -> list[dict]:
614
+ ws_root(workspace)
615
+ return manager.sessions.list(workspace)
616
+
617
+ @api.get("/workspaces/{workspace}/sessions/{task_id}")
618
+ def get_session(workspace: WorkspaceId, task_id: str) -> dict:
619
+ ws_root(workspace)
620
+ found = manager.sessions.load(workspace, task_id)
621
+ if found is None:
622
+ raise HTTPException(404, "oturum yok")
623
+ return found
624
+
625
+ @api.post("/tasks/{task_id}/messages", status_code=202)
626
+ def task_message(task_id: str, body: MessageIn) -> dict:
627
+ get_task(task_id)
628
+ try:
629
+ manager.post_message(task_id, body.text)
630
+ except TaskFinished:
631
+ raise HTTPException(409, "görev bitti") from None
632
+ return {"queued": True}
633
+
634
+ @api.get("/model")
635
+ def get_model() -> dict:
636
+ ctl = manager.model_control
637
+ if ctl is None:
638
+ raise HTTPException(404, "model değiştirme bu sunucuda yok")
639
+ return ctl.info()
640
+
641
+ @api.put("/model")
642
+ def put_model(body: ModelIn) -> dict:
643
+ ctl = manager.model_control
644
+ if ctl is None:
645
+ raise HTTPException(404, "model değiştirme bu sunucuda yok")
646
+ if manager.any_busy():
647
+ raise HTTPException(409, "görev sürerken model değiştirilemez")
648
+ try:
649
+ return ctl.switch(body.model, body.api)
650
+ except ModelSwitchError as e:
651
+ raise HTTPException(e.status, e.message) from None
652
+
653
+ # --- bağlı API'ler (anahtarlar geri verilmez) --------------------------------------------
654
+ def api_rows() -> list[dict]:
655
+ ctl = manager.model_control
656
+ rows = (
657
+ [
658
+ {
659
+ "name": "varsayılan",
660
+ "endpoint": ctl.default.endpoint if ctl else "",
661
+ "has_key": bool(ctl and ctl.default.key),
662
+ "builtin": True,
663
+ }
664
+ ]
665
+ if ctl
666
+ else []
667
+ )
668
+ try:
669
+ stored = apis.load(home)
670
+ except apis.ApiError as e:
671
+ raise HTTPException(500, str(e)) from None
672
+ rows += [
673
+ {"name": a.name, "endpoint": a.endpoint, "has_key": bool(a.key), "builtin": False}
674
+ for a in stored.values()
675
+ ]
676
+ return rows
677
+
678
+ @api.get("/apis")
679
+ def list_apis() -> list[dict]:
680
+ return api_rows()
681
+
682
+ @api.post("/apis", status_code=201)
683
+ def add_api(body: ApiIn) -> dict:
684
+ try:
685
+ a = apis.add(home, body.name, body.endpoint, body.key)
686
+ except apis.ApiError as e:
687
+ raise HTTPException(409 if "zaten" in str(e) else 400, str(e)) from None
688
+ return {"name": a.name}
689
+
690
+ @api.put("/apis/{name}/key")
691
+ def set_api_key(name: ServerName, body: KeyIn) -> dict:
692
+ try:
693
+ apis.set_key(home, name, body.key)
694
+ except apis.ApiError as e:
695
+ raise HTTPException(404 if "yok" in str(e) else 400, str(e)) from None
696
+ return {"has_key": bool(body.key)}
697
+
698
+ @api.delete("/apis/{name}")
699
+ def delete_api(name: ServerName) -> dict:
700
+ ctl = manager.model_control
701
+ if ctl is not None and ctl.api == name:
702
+ raise HTTPException(409, "kullanılan API silinemez; önce başka bir model seç")
703
+ try:
704
+ apis.remove(home, name)
705
+ except apis.ApiError as e:
706
+ raise HTTPException(404, str(e)) from None
707
+ return {"removed": name}
708
+
709
+ @api.get("/tasks/{task_id}")
710
+ def task_info(task_id: str) -> dict:
711
+ return get_task(task_id).public()
712
+
713
+ @api.get("/tasks/{task_id}/events")
714
+ def task_events(task_id: str, last_event_id: Annotated[str | None, Header()] = None):
715
+ task = get_task(task_id)
716
+ try:
717
+ nxt = int(last_event_id) + 1 if last_event_id is not None else 0
718
+ except ValueError:
719
+ raise HTTPException(400, "Last-Event-ID geçersiz") from None
720
+
721
+ def stream():
722
+ i = max(nxt, 0)
723
+ while True:
724
+ events, finished = task.wait_events(i, KEEPALIVE)
725
+ for e in events:
726
+ data = json.dumps(e["data"], ensure_ascii=False)
727
+ yield f"id: {e['i']}\nevent: {e['type']}\ndata: {data}\n\n"
728
+ i += len(events)
729
+ if finished and not events:
730
+ return
731
+ if not events:
732
+ yield ": keepalive\n\n"
733
+
734
+ return StreamingResponse(
735
+ stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"}
736
+ )
737
+
738
+ @api.post("/tasks/{task_id}/cancel", status_code=202)
739
+ def cancel_task(task_id: str) -> dict:
740
+ get_task(task_id)
741
+ manager.cancel(task_id)
742
+ return get_task(task_id).public()
743
+
744
+ @api.post("/tasks/{task_id}/revert")
745
+ def revert_task(task_id: str) -> dict:
746
+ task = get_task(task_id)
747
+ if not task.finished.is_set():
748
+ raise HTTPException(409, "görev sürüyor")
749
+ try:
750
+ manager.revert(task_id)
751
+ except WorkspaceBusy:
752
+ raise HTTPException(409, "çalışma alanında görev sürüyor") from None
753
+ except RevertRefused as e:
754
+ raise HTTPException(409, str(e)) from None
755
+ return {"reverted": task_id}
756
+
757
+ @api.get("/tasks/{task_id}/diff", response_class=PlainTextResponse)
758
+ def task_diff(task_id: str) -> str:
759
+ task = get_task(task_id)
760
+ if not task.finished.is_set():
761
+ raise HTTPException(409, "görev sürüyor")
762
+ return task.diff
763
+
764
+ @api.post("/approvals/{approval_id}")
765
+ def resolve_approval(approval_id: str, body: ApprovalIn) -> dict:
766
+ try:
767
+ manager.resolve_approval(approval_id, body.decision == "allow")
768
+ except KeyError:
769
+ raise HTTPException(404, "onay isteği yok") from None
770
+ return {"decision": body.decision}
771
+
772
+ @api.get("/audit")
773
+ def audit(
774
+ workspace: Annotated[str, Query(pattern=ID_PATTERN)],
775
+ limit: Annotated[int, Query(ge=1, le=500)] = 100,
776
+ ) -> list[dict]:
777
+ path = home / "audit" / f"{workspace}.jsonl"
778
+ if not path.is_file():
779
+ return []
780
+ with path.open() as f:
781
+ return [json.loads(line) for line in deque(f, maxlen=limit)]
782
+
783
+ @app.get("/preview/{workspace}/{key}/{path:path}", include_in_schema=False)
784
+ def preview(workspace: WorkspaceId, key: str, path: str = "") -> Response:
785
+ """Çalışma alanındaki statik dosyaları önizler (yalnız GET, anahtar çalışma alanına bağlı)."""
786
+ if not hmac.compare_digest(key.encode(), preview_key(workspace).encode()):
787
+ raise HTTPException(403, "önizleme anahtarı geçersiz")
788
+ root = ws_root(workspace)
789
+ try:
790
+ full = resolve_path(root, path or ".")
791
+ except PathEscapeError:
792
+ raise HTTPException(404, "yok") from None
793
+ if full.is_dir():
794
+ try: # index.html de yeniden çözülür: sembolik bağ ile dışarı kaçamaz
795
+ full = resolve_path(root, f"{path.rstrip('/') or '.'}/index.html")
796
+ except PathEscapeError:
797
+ raise HTTPException(404, "yok") from None
798
+ parts = full.relative_to(root.resolve()).parts
799
+ if not full.is_file() or any(p in filetree.HIDDEN for p in parts):
800
+ raise HTTPException(404, "yok")
801
+ if full.stat().st_size > PREVIEW_MAX:
802
+ raise HTTPException(413, "dosya çok büyük")
803
+ ctype = mimetypes.guess_type(full.name)[0] or "application/octet-stream"
804
+ if ctype.startswith("text/") or ctype in ("application/javascript", "application/json"):
805
+ ctype += "; charset=utf-8"
806
+ return Response(full.read_bytes(), media_type=ctype, headers=PREVIEW_HEADERS)
807
+
808
+ app.include_router(api)
809
+
810
+ if login is not None: # terminalin açtığı portal: tek kullanımlık kodu jetonla takas eder
811
+
812
+ @app.post("/auth/exchange")
813
+ def exchange(body: CodeIn) -> dict:
814
+ if not login.redeem(body.code):
815
+ raise HTTPException(401, "kod geçersiz, süresi dolmuş veya kullanılmış")
816
+ return {"token": token}
817
+
818
+ # Arayüz dosyaları jetonsuz sunulur (içlerinde sır yok); sabit rota listesi, dizin gezinmesi yok.
819
+ for route, (name, ctype) in UI_FILES.items():
820
+ body = (WEB_DIR / name).read_bytes()
821
+
822
+ def ui(body: bytes = body, ctype: str = ctype) -> Response:
823
+ return Response(body, media_type=ctype, headers=UI_HEADERS)
824
+
825
+ app.add_api_route(route, ui, methods=["GET"], include_in_schema=False)
826
+
827
+ app.add_api_route(
828
+ "/favicon.ico",
829
+ lambda: Response(status_code=204, headers=UI_HEADERS),
830
+ methods=["GET"],
831
+ include_in_schema=False,
832
+ )
833
+
834
+ return app