toolframe-core 0.6.2__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.
- toolframe_core/__init__.py +3 -0
- toolframe_core/_state_postgres.py +420 -0
- toolframe_core/_state_sqlite.py +339 -0
- toolframe_core/access.py +513 -0
- toolframe_core/api.py +700 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
- toolframe_core/cli.py +681 -0
- toolframe_core/config.py +267 -0
- toolframe_core/diagnostics.py +200 -0
- toolframe_core/graph.py +207 -0
- toolframe_core/host.py +2610 -0
- toolframe_core/maintenance.py +868 -0
- toolframe_core/mcp_access.py +259 -0
- toolframe_core/network.py +97 -0
- toolframe_core/oidc_access.py +187 -0
- toolframe_core/packages.py +728 -0
- toolframe_core/process.py +212 -0
- toolframe_core/redaction.py +120 -0
- toolframe_core/scaffold.py +142 -0
- toolframe_core/skills.py +210 -0
- toolframe_core/state.py +226 -0
- toolframe_core/state_admin.py +270 -0
- toolframe_core/stores.py +88 -0
- toolframe_core/templates.json +6 -0
- toolframe_core/templates.py +56 -0
- toolframe_core-0.6.2.dist-info/METADATA +465 -0
- toolframe_core-0.6.2.dist-info/RECORD +46 -0
- toolframe_core-0.6.2.dist-info/WHEEL +4 -0
- toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
- toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
- toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
toolframe_core/api.py
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""Stable management API and generation-gated HTTP/WebSocket entrance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import asynccontextmanager, suppress
|
|
10
|
+
from http.cookies import SimpleCookie
|
|
11
|
+
from urllib.parse import parse_qs, urlsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from fastapi import FastAPI, Request, WebSocket
|
|
15
|
+
from fastapi.responses import (
|
|
16
|
+
HTMLResponse,
|
|
17
|
+
JSONResponse,
|
|
18
|
+
RedirectResponse,
|
|
19
|
+
Response,
|
|
20
|
+
StreamingResponse,
|
|
21
|
+
)
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
23
|
+
from starlette.background import BackgroundTask
|
|
24
|
+
from starlette.requests import ClientDisconnect
|
|
25
|
+
from toolframe_sdk import PluginError, Principal
|
|
26
|
+
from toolframe_sdk.tracing import TracingMiddleware
|
|
27
|
+
from websockets.asyncio.client import connect
|
|
28
|
+
from websockets.exceptions import ConnectionClosed
|
|
29
|
+
|
|
30
|
+
from .access import CONTEXT_HEADER, PASSWORD_COOKIE, operation_scopes
|
|
31
|
+
from .network import trusted
|
|
32
|
+
from .oidc_access import BROWSER_COOKIE, PUBLIC_PATHS, cross_site_route, install_routes
|
|
33
|
+
from .redaction import redact_toml
|
|
34
|
+
|
|
35
|
+
COOKIE = "toolframe_session"
|
|
36
|
+
HOP_HEADERS = {
|
|
37
|
+
"connection",
|
|
38
|
+
"keep-alive",
|
|
39
|
+
"proxy-authenticate",
|
|
40
|
+
"proxy-authorization",
|
|
41
|
+
"te",
|
|
42
|
+
"trailer",
|
|
43
|
+
"transfer-encoding",
|
|
44
|
+
"upgrade",
|
|
45
|
+
"host",
|
|
46
|
+
"content-length",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def provider_cookies(connection):
|
|
51
|
+
cookies = SimpleCookie()
|
|
52
|
+
for name, value in connection.cookies.items():
|
|
53
|
+
if name not in {COOKIE, PASSWORD_COOKIE, BROWSER_COOKIE}:
|
|
54
|
+
cookies[name] = value
|
|
55
|
+
return cookies.output(header="", sep=";").strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OperationRequest(BaseModel):
|
|
59
|
+
model_config = ConfigDict(extra="forbid")
|
|
60
|
+
action: str
|
|
61
|
+
params: dict = Field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def error_status(error):
|
|
65
|
+
code = error.code
|
|
66
|
+
if code in {
|
|
67
|
+
"stale_plan",
|
|
68
|
+
"stale_revision",
|
|
69
|
+
"idempotency_conflict",
|
|
70
|
+
"already_installed",
|
|
71
|
+
"mutation_conflict",
|
|
72
|
+
}:
|
|
73
|
+
return 409
|
|
74
|
+
if code in {"plugin_missing", "operation_missing", "candidate_missing"}:
|
|
75
|
+
return 404
|
|
76
|
+
if code == "forbidden":
|
|
77
|
+
return 403
|
|
78
|
+
if code in {"authentication_required", "invalid_credentials", "session_invalid"}:
|
|
79
|
+
return 401
|
|
80
|
+
if code == "rate_limited":
|
|
81
|
+
return 429
|
|
82
|
+
if code in {
|
|
83
|
+
"invalid_request",
|
|
84
|
+
"action_unknown",
|
|
85
|
+
"configuration_invalid",
|
|
86
|
+
"config_invalid",
|
|
87
|
+
}:
|
|
88
|
+
return 422
|
|
89
|
+
if code in {
|
|
90
|
+
"host_closing",
|
|
91
|
+
"core_state_unavailable",
|
|
92
|
+
"core_state_ownership_lost",
|
|
93
|
+
"plugin_reloading",
|
|
94
|
+
"dependency_unavailable",
|
|
95
|
+
"presentation_unavailable",
|
|
96
|
+
"peer_closed",
|
|
97
|
+
"cleanup_failed",
|
|
98
|
+
"auth_unavailable",
|
|
99
|
+
"auth_transition",
|
|
100
|
+
}:
|
|
101
|
+
return 503
|
|
102
|
+
return 400
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def provider_for(host, path):
|
|
106
|
+
kind = (
|
|
107
|
+
"mcp"
|
|
108
|
+
if path == "mcp" or path.startswith("mcp/")
|
|
109
|
+
else "llms"
|
|
110
|
+
if path == "llms.txt" or path.startswith("llms/")
|
|
111
|
+
else "dashboard"
|
|
112
|
+
)
|
|
113
|
+
name = host.graph.providers.get(kind)
|
|
114
|
+
runtime = host.runtimes.get(name)
|
|
115
|
+
if (
|
|
116
|
+
not runtime
|
|
117
|
+
or not host.healthy_runtime(name)
|
|
118
|
+
or not runtime.admitted
|
|
119
|
+
or name in host.gated
|
|
120
|
+
or runtime.epoch != host.epoch
|
|
121
|
+
or host.publication.get(kind, {}).get("state") != "available"
|
|
122
|
+
):
|
|
123
|
+
raise PluginError("presentation_unavailable", f"{kind} entrance is unavailable")
|
|
124
|
+
address = runtime.health.get("http_url")
|
|
125
|
+
if not address or urlsplit(address).hostname != "127.0.0.1":
|
|
126
|
+
raise PluginError(
|
|
127
|
+
"provider_address_invalid",
|
|
128
|
+
"Provider must report a private loopback HTTP address",
|
|
129
|
+
)
|
|
130
|
+
return kind, runtime, address
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_app(host, *, manage_lifespan=True):
|
|
134
|
+
@asynccontextmanager
|
|
135
|
+
async def lifespan(app):
|
|
136
|
+
try:
|
|
137
|
+
if manage_lifespan:
|
|
138
|
+
await host.start(wait=False)
|
|
139
|
+
yield
|
|
140
|
+
finally:
|
|
141
|
+
if manage_lifespan:
|
|
142
|
+
await host.close()
|
|
143
|
+
|
|
144
|
+
app = FastAPI(
|
|
145
|
+
title="Toolframe core",
|
|
146
|
+
lifespan=lifespan,
|
|
147
|
+
docs_url=None,
|
|
148
|
+
redoc_url=None,
|
|
149
|
+
openapi_url=None,
|
|
150
|
+
)
|
|
151
|
+
@app.middleware("http")
|
|
152
|
+
async def application_operator(request, call_next):
|
|
153
|
+
path = request.url.path
|
|
154
|
+
identity = host.access.identity(request)
|
|
155
|
+
if not trusted(
|
|
156
|
+
request.headers,
|
|
157
|
+
host.listen_host,
|
|
158
|
+
public_url=host.composition.application.public_url,
|
|
159
|
+
cross_site=cross_site_route(path, request.method),
|
|
160
|
+
navigation=request.method == "GET"
|
|
161
|
+
and path in {"/", "/_toolframe/auth/login"}
|
|
162
|
+
and not identity.local,
|
|
163
|
+
):
|
|
164
|
+
return JSONResponse(
|
|
165
|
+
{
|
|
166
|
+
"error": {
|
|
167
|
+
"code": "untrusted_origin",
|
|
168
|
+
"message": "Use the application origin",
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
status_code=403,
|
|
172
|
+
)
|
|
173
|
+
request.state.identity = identity
|
|
174
|
+
try:
|
|
175
|
+
response = await call_next(request)
|
|
176
|
+
if path in PUBLIC_PATHS:
|
|
177
|
+
response.headers["access-control-allow-origin"] = "*"
|
|
178
|
+
response.headers["access-control-allow-methods"] = "GET, POST, OPTIONS"
|
|
179
|
+
response.headers["access-control-allow-headers"] = (
|
|
180
|
+
"Authorization, Content-Type"
|
|
181
|
+
)
|
|
182
|
+
response.headers["cache-control"] = "no-store"
|
|
183
|
+
if path.startswith("/_toolframe/"):
|
|
184
|
+
response.headers["cache-control"] = "no-store"
|
|
185
|
+
response.headers["referrer-policy"] = "no-referrer"
|
|
186
|
+
return response
|
|
187
|
+
except PluginError as error:
|
|
188
|
+
return JSONResponse(
|
|
189
|
+
{"error": host.redact(error.as_dict())}, status_code=error_status(error)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@app.exception_handler(PluginError)
|
|
193
|
+
async def plugin_error(request, error):
|
|
194
|
+
return JSONResponse(
|
|
195
|
+
{"error": host.redact(error.as_dict())}, status_code=error_status(error)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def require(request, action, params=None, *, activity=False):
|
|
199
|
+
return await host.access.admit(
|
|
200
|
+
request.state.identity,
|
|
201
|
+
operation_scopes(action, params),
|
|
202
|
+
effective=False,
|
|
203
|
+
activity=activity,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def bounded_body(request):
|
|
207
|
+
body = bytearray()
|
|
208
|
+
async for chunk in request.stream():
|
|
209
|
+
body.extend(chunk)
|
|
210
|
+
if len(body) > 16384:
|
|
211
|
+
raise PluginError("invalid_request", "Auth request exceeds 16 KiB")
|
|
212
|
+
try:
|
|
213
|
+
if request.headers.get("content-type", "").startswith("application/json"):
|
|
214
|
+
result = json.loads(body)
|
|
215
|
+
else:
|
|
216
|
+
result = {k: v[0] for k, v in parse_qs(body.decode()).items()}
|
|
217
|
+
if not isinstance(result, dict):
|
|
218
|
+
raise TypeError()
|
|
219
|
+
return result
|
|
220
|
+
except ValueError, UnicodeError, TypeError:
|
|
221
|
+
raise PluginError("invalid_request", "Supply an object or form") from None
|
|
222
|
+
|
|
223
|
+
@app.get("/healthz")
|
|
224
|
+
async def health():
|
|
225
|
+
return {"core": "ready", "host_run": host.host_run}
|
|
226
|
+
|
|
227
|
+
install_routes(app, host)
|
|
228
|
+
|
|
229
|
+
@app.get("/_toolframe/auth/context")
|
|
230
|
+
async def access_context(request: Request):
|
|
231
|
+
return await host.access.presentation(request.state.identity)
|
|
232
|
+
|
|
233
|
+
@app.api_route("/_toolframe/auth/login", methods=["GET", "POST"])
|
|
234
|
+
async def password_login(request: Request):
|
|
235
|
+
if request.method == "POST":
|
|
236
|
+
data = await bounded_body(request)
|
|
237
|
+
result = await host.access.login(
|
|
238
|
+
data.get("password"),
|
|
239
|
+
request.client.host if request.client else "unknown",
|
|
240
|
+
)
|
|
241
|
+
response = (
|
|
242
|
+
JSONResponse({"status": "signed_in"})
|
|
243
|
+
if "application/json" in request.headers.get("accept", "")
|
|
244
|
+
else RedirectResponse("/", status_code=303)
|
|
245
|
+
)
|
|
246
|
+
response.set_cookie(
|
|
247
|
+
PASSWORD_COOKIE,
|
|
248
|
+
result["token"],
|
|
249
|
+
httponly=True,
|
|
250
|
+
samesite="strict",
|
|
251
|
+
secure=host.composition.application.public_url.startswith("https://")
|
|
252
|
+
or request.url.scheme == "https",
|
|
253
|
+
max_age=max(1, int(result["expires_at"] - time.time())),
|
|
254
|
+
)
|
|
255
|
+
return response
|
|
256
|
+
name = host.composition.access.dashboard_auth
|
|
257
|
+
try:
|
|
258
|
+
result = await host.call_service(
|
|
259
|
+
name, "dashboard-auth.presentation", "login", {}
|
|
260
|
+
)
|
|
261
|
+
if not isinstance(result.get("html"), str):
|
|
262
|
+
raise TypeError("Invalid login presentation")
|
|
263
|
+
return HTMLResponse(result["html"])
|
|
264
|
+
except Exception: # noqa: BLE001 - an unavailable login integration selects fallback
|
|
265
|
+
if name not in host.transitioning and not host.starting:
|
|
266
|
+
host.access.mark_failed(name)
|
|
267
|
+
return HTMLResponse(
|
|
268
|
+
'<!doctype html><title>Toolframe</title><p>Login is unavailable.</p><a href="/">Open Dashboard</a>',
|
|
269
|
+
status_code=503,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@app.post("/_toolframe/auth/logout")
|
|
273
|
+
async def logout(request: Request):
|
|
274
|
+
token = request.cookies.get(PASSWORD_COOKIE)
|
|
275
|
+
if token:
|
|
276
|
+
with suppress(PluginError):
|
|
277
|
+
await host.access.auth("logout", {"token": token})
|
|
278
|
+
response = RedirectResponse("/", status_code=303)
|
|
279
|
+
response.delete_cookie(PASSWORD_COOKIE)
|
|
280
|
+
response.delete_cookie(COOKIE)
|
|
281
|
+
return response
|
|
282
|
+
|
|
283
|
+
@app.post("/_toolframe/v1/auth/{action}")
|
|
284
|
+
async def auth_operation(action: str, request: Request):
|
|
285
|
+
return await host.access.manage(
|
|
286
|
+
request.state.identity, action, await bounded_body(request)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
@app.post("/_toolframe/v1/mcp-auth/{action}")
|
|
290
|
+
async def mcp_auth_operation(action: str, request: Request):
|
|
291
|
+
return await host.mcp_access.manage(
|
|
292
|
+
request.state.identity, action, await bounded_body(request)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
@app.api_route("/_toolframe/session", methods=["GET", "POST"])
|
|
296
|
+
async def session(request: Request):
|
|
297
|
+
token = request.query_params.get("token", "")
|
|
298
|
+
if request.method == "POST":
|
|
299
|
+
data = await request.body()
|
|
300
|
+
if len(data) > 4096:
|
|
301
|
+
return JSONResponse({"error": "Request too large"}, status_code=413)
|
|
302
|
+
try:
|
|
303
|
+
token = parse_qs(data.decode()).get("token", [""])[0]
|
|
304
|
+
except UnicodeError:
|
|
305
|
+
return JSONResponse(
|
|
306
|
+
{"error": "Invalid request encoding"}, status_code=422
|
|
307
|
+
)
|
|
308
|
+
if token:
|
|
309
|
+
if not hmac.compare_digest(token.encode(), host.token.encode()):
|
|
310
|
+
return JSONResponse(
|
|
311
|
+
{"error": "Invalid management token"}, status_code=401
|
|
312
|
+
)
|
|
313
|
+
response = RedirectResponse("/", status_code=303)
|
|
314
|
+
response.set_cookie(
|
|
315
|
+
COOKIE,
|
|
316
|
+
token,
|
|
317
|
+
httponly=True,
|
|
318
|
+
samesite="strict",
|
|
319
|
+
secure=host.composition.application.public_url.startswith("https://")
|
|
320
|
+
or request.url.scheme == "https",
|
|
321
|
+
)
|
|
322
|
+
response.headers["referrer-policy"] = "no-referrer"
|
|
323
|
+
response.headers["cache-control"] = "no-store"
|
|
324
|
+
return response
|
|
325
|
+
return HTMLResponse(
|
|
326
|
+
'<!doctype html><title>Toolframe sign in</title><h1>Toolframe</h1><form method="post">'
|
|
327
|
+
'<label>Management token <input type="password" name="token" required></label>'
|
|
328
|
+
"<button>Open Dashboard</button></form><p>On the server, run <code>toolframe open</code> for a local sign-in link. "
|
|
329
|
+
"For LAN access, enter the token from <code>APP/.toolframe/management.token</code>.</p>"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
@app.get("/_toolframe/v1/status")
|
|
333
|
+
async def status(request: Request):
|
|
334
|
+
await require(request, "status")
|
|
335
|
+
return await host.status()
|
|
336
|
+
|
|
337
|
+
@app.get("/_toolframe/v1/readiness")
|
|
338
|
+
async def readiness(request: Request):
|
|
339
|
+
await require(request, "readiness")
|
|
340
|
+
report = await host.status()
|
|
341
|
+
return JSONResponse(report, status_code=200 if report["ready"] else 503)
|
|
342
|
+
|
|
343
|
+
@app.get("/_toolframe/v1/composition")
|
|
344
|
+
async def composition(request: Request):
|
|
345
|
+
await require(request, "composition")
|
|
346
|
+
try:
|
|
347
|
+
text = redact_toml(host.config.path.read_text(), host.redact)
|
|
348
|
+
except ValueError as error:
|
|
349
|
+
raise PluginError(
|
|
350
|
+
"configuration_invalid", "Repair invalid TOML before exporting"
|
|
351
|
+
) from error
|
|
352
|
+
return {"toml": text, "revision": host.config.revision, "secrets": "redacted"}
|
|
353
|
+
|
|
354
|
+
@app.get("/_toolframe/v1/operations")
|
|
355
|
+
async def operations(
|
|
356
|
+
request: Request,
|
|
357
|
+
page: int | None = None,
|
|
358
|
+
page_size: int = 10,
|
|
359
|
+
through: int | None = None,
|
|
360
|
+
target: str | None = None,
|
|
361
|
+
kind: str | None = None,
|
|
362
|
+
status: str | None = None,
|
|
363
|
+
):
|
|
364
|
+
await require(request, "operations")
|
|
365
|
+
if request.query_params:
|
|
366
|
+
return host.redact(
|
|
367
|
+
await host.state.operation_page(
|
|
368
|
+
{
|
|
369
|
+
"page": page if page is not None else 1,
|
|
370
|
+
"page_size": page_size,
|
|
371
|
+
"through": through,
|
|
372
|
+
"target": target,
|
|
373
|
+
"kind": kind,
|
|
374
|
+
"status": status,
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
# Preserve the existing bounded list response for clients without queries.
|
|
379
|
+
return host.redact(await host.state.operations())
|
|
380
|
+
|
|
381
|
+
@app.get("/_toolframe/v1/operations/{identifier}")
|
|
382
|
+
async def operation(identifier: str, request: Request):
|
|
383
|
+
await require(request, "operation")
|
|
384
|
+
return host.redact(await host.state.operation(identifier))
|
|
385
|
+
|
|
386
|
+
@app.post("/_toolframe/v1/operations/{identifier}/cancel")
|
|
387
|
+
async def cancel(identifier: str, request: Request):
|
|
388
|
+
current = await host.state.operation(identifier)
|
|
389
|
+
await require(request, current["kind"], current["request"], activity=True)
|
|
390
|
+
if current["status"] not in ("queued", "running"):
|
|
391
|
+
return {"id": identifier, "requested": False, "status": current["status"]}
|
|
392
|
+
host.cancellations.add(identifier)
|
|
393
|
+
host.changed.set()
|
|
394
|
+
return {"id": identifier, "requested": True, "phase": current["phase"]}
|
|
395
|
+
|
|
396
|
+
@app.post("/_toolframe/v1/operations", status_code=202)
|
|
397
|
+
async def submit(request: Request, body: OperationRequest):
|
|
398
|
+
await require(request, body.action, body.params)
|
|
399
|
+
return host.redact(
|
|
400
|
+
await host.submit(
|
|
401
|
+
body.action,
|
|
402
|
+
body.params,
|
|
403
|
+
idempotency=request.headers.get("idempotency-key"),
|
|
404
|
+
identity=request.state.identity,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
@app.post("/_toolframe/v1/plugins/{identifier}/{action}", status_code=202)
|
|
409
|
+
async def plugin_operation(
|
|
410
|
+
identifier: str, action: str, request: Request, body: dict
|
|
411
|
+
):
|
|
412
|
+
await require(request, action, {**body, "id": identifier})
|
|
413
|
+
return host.redact(
|
|
414
|
+
await host.submit(
|
|
415
|
+
action,
|
|
416
|
+
{**body, "id": identifier},
|
|
417
|
+
idempotency=request.headers.get("idempotency-key"),
|
|
418
|
+
identity=request.state.identity,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
@app.get("/_toolframe/v1/plugins/{identifier}/logs")
|
|
423
|
+
async def logs(identifier: str, request: Request):
|
|
424
|
+
await require(request, "logs")
|
|
425
|
+
runtime = host.runtimes.get(identifier)
|
|
426
|
+
return host.redact(
|
|
427
|
+
{
|
|
428
|
+
"plugin_id": identifier,
|
|
429
|
+
"lines": list(runtime.worker.logs) if runtime else [],
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
@app.post("/_toolframe/v1/services/{owner}/{service}/{method}")
|
|
434
|
+
async def service_call(owner: str, service: str, method: str, request: Request):
|
|
435
|
+
definition = host.graph.definitions.get(owner)
|
|
436
|
+
spec = (
|
|
437
|
+
definition.services.get(service).methods.get(method)
|
|
438
|
+
if definition and service in definition.services
|
|
439
|
+
else None
|
|
440
|
+
)
|
|
441
|
+
if spec is None or spec.audience == "internal":
|
|
442
|
+
raise PluginError("forbidden", "This service is not a visitor-facing API")
|
|
443
|
+
actor = await host.access.admit(
|
|
444
|
+
request.state.identity, spec.required_scopes, activity=True
|
|
445
|
+
)
|
|
446
|
+
return await host.call_service(
|
|
447
|
+
owner, service, method, await request.json(), principal=actor
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
@app.post("/api/{owner}/{service}/{method}")
|
|
451
|
+
async def public_business_call(
|
|
452
|
+
owner: str, service: str, method: str, request: Request
|
|
453
|
+
):
|
|
454
|
+
definition = host.graph.definitions.get(owner)
|
|
455
|
+
spec = (
|
|
456
|
+
definition.services.get(service).methods.get(method)
|
|
457
|
+
if definition and service in definition.services
|
|
458
|
+
else None
|
|
459
|
+
)
|
|
460
|
+
if spec is None or not spec.public_api or spec.audience != "business":
|
|
461
|
+
raise PluginError("forbidden", "This method has no public API declaration")
|
|
462
|
+
return await host.call_service(
|
|
463
|
+
owner,
|
|
464
|
+
service,
|
|
465
|
+
method,
|
|
466
|
+
await request.json(),
|
|
467
|
+
principal=Principal(kind="public", all_business=True),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
@app.api_route(
|
|
471
|
+
"/{path:path}",
|
|
472
|
+
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
|
473
|
+
)
|
|
474
|
+
async def gateway(path: str, request: Request):
|
|
475
|
+
if path.startswith("_toolframe/"):
|
|
476
|
+
return JSONResponse({"error": "Unknown management route"}, status_code=404)
|
|
477
|
+
if not path and "dashboard" not in host.graph.providers:
|
|
478
|
+
return JSONResponse(
|
|
479
|
+
{
|
|
480
|
+
"name": host.composition.application.name,
|
|
481
|
+
"core": "ready",
|
|
482
|
+
"management": "/_toolframe/v1/status",
|
|
483
|
+
"dashboard": "not_installed",
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
try:
|
|
487
|
+
kind, runtime, address = provider_for(host, path)
|
|
488
|
+
except PluginError as error:
|
|
489
|
+
return JSONResponse(
|
|
490
|
+
{"error": error.as_dict()},
|
|
491
|
+
status_code=503,
|
|
492
|
+
headers={"Retry-After": "1"},
|
|
493
|
+
)
|
|
494
|
+
if kind == "dashboard":
|
|
495
|
+
try:
|
|
496
|
+
await host.access.admit(request.state.identity)
|
|
497
|
+
except PluginError as error:
|
|
498
|
+
if error.code == "authentication_required":
|
|
499
|
+
return RedirectResponse("/_toolframe/auth/login", status_code=303)
|
|
500
|
+
raise
|
|
501
|
+
mcp_reference = None
|
|
502
|
+
if kind == "mcp":
|
|
503
|
+
try:
|
|
504
|
+
mcp_reference = await host.mcp_access.register(runtime, request.headers)
|
|
505
|
+
except PluginError as error:
|
|
506
|
+
status = error_status(error)
|
|
507
|
+
challenge = (
|
|
508
|
+
'Bearer error="invalid_token"'
|
|
509
|
+
if error.code == "invalid_credentials"
|
|
510
|
+
else "Bearer"
|
|
511
|
+
)
|
|
512
|
+
if (
|
|
513
|
+
host.composition.access.oidc_provider
|
|
514
|
+
and host.composition.application.public_url
|
|
515
|
+
):
|
|
516
|
+
separator = ", " if challenge != "Bearer" else " "
|
|
517
|
+
challenge += (
|
|
518
|
+
separator
|
|
519
|
+
+ 'resource_metadata="'
|
|
520
|
+
+ host.composition.application.public_url
|
|
521
|
+
+ '/.well-known/oauth-protected-resource/mcp"'
|
|
522
|
+
)
|
|
523
|
+
return JSONResponse(
|
|
524
|
+
{"error": error.as_dict()},
|
|
525
|
+
status_code=status,
|
|
526
|
+
headers={
|
|
527
|
+
"Cache-Control": "no-store",
|
|
528
|
+
**({"WWW-Authenticate": challenge} if status == 401 else {}),
|
|
529
|
+
**({"Retry-After": "1"} if status == 503 else {}),
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
headers = {
|
|
533
|
+
key: value
|
|
534
|
+
for key, value in request.headers.items()
|
|
535
|
+
if key.lower() not in HOP_HEADERS
|
|
536
|
+
and key.lower() not in {"authorization", "cookie"}
|
|
537
|
+
and not key.lower().startswith("x-toolframe-")
|
|
538
|
+
}
|
|
539
|
+
if "origin" in headers:
|
|
540
|
+
headers["origin"] = address
|
|
541
|
+
if mcp_reference:
|
|
542
|
+
headers["authorization"] = "Bearer " + mcp_reference
|
|
543
|
+
if cookies := provider_cookies(request):
|
|
544
|
+
headers["cookie"] = cookies
|
|
545
|
+
if host.tracing:
|
|
546
|
+
host.tracing.inject(headers)
|
|
547
|
+
url = f"{address}/{path}"
|
|
548
|
+
if request.url.query:
|
|
549
|
+
url += "?" + request.url.query
|
|
550
|
+
client = httpx.AsyncClient(timeout=None, trust_env=False)
|
|
551
|
+
try:
|
|
552
|
+
upstream_request = client.build_request(
|
|
553
|
+
request.method, url, headers=headers, content=request.stream()
|
|
554
|
+
)
|
|
555
|
+
upstream = await client.send(upstream_request, stream=True)
|
|
556
|
+
except ClientDisconnect:
|
|
557
|
+
host.mcp_access.release(mcp_reference)
|
|
558
|
+
await client.aclose()
|
|
559
|
+
# The caller left before its upload finished; this is not a server error.
|
|
560
|
+
return Response(status_code=499)
|
|
561
|
+
except BaseException:
|
|
562
|
+
host.mcp_access.release(mcp_reference)
|
|
563
|
+
await client.aclose()
|
|
564
|
+
raise
|
|
565
|
+
|
|
566
|
+
async def close():
|
|
567
|
+
host.mcp_access.release(mcp_reference)
|
|
568
|
+
await upstream.aclose()
|
|
569
|
+
await client.aclose()
|
|
570
|
+
|
|
571
|
+
async def stream():
|
|
572
|
+
task = asyncio.current_task()
|
|
573
|
+
host.gateway_sessions.setdefault(runtime.name, set()).add(task)
|
|
574
|
+
try:
|
|
575
|
+
async for chunk in upstream.aiter_raw():
|
|
576
|
+
yield chunk
|
|
577
|
+
finally:
|
|
578
|
+
host.gateway_sessions.get(runtime.name, set()).discard(task)
|
|
579
|
+
await close()
|
|
580
|
+
|
|
581
|
+
response = StreamingResponse(
|
|
582
|
+
stream(), status_code=upstream.status_code, background=BackgroundTask(close)
|
|
583
|
+
)
|
|
584
|
+
response.raw_headers = [
|
|
585
|
+
(key, value)
|
|
586
|
+
for key, value in upstream.headers.raw
|
|
587
|
+
if key.decode().lower() not in HOP_HEADERS
|
|
588
|
+
]
|
|
589
|
+
return response
|
|
590
|
+
|
|
591
|
+
@app.websocket("/{path:path}")
|
|
592
|
+
async def websocket(path: str, downstream: WebSocket):
|
|
593
|
+
if not trusted(
|
|
594
|
+
downstream.headers,
|
|
595
|
+
host.listen_host,
|
|
596
|
+
public_url=host.composition.application.public_url,
|
|
597
|
+
) or path.startswith("_toolframe/"):
|
|
598
|
+
await downstream.close(code=1008)
|
|
599
|
+
return
|
|
600
|
+
try:
|
|
601
|
+
kind, runtime, address = provider_for(host, path)
|
|
602
|
+
except PluginError:
|
|
603
|
+
await downstream.close(code=1013)
|
|
604
|
+
return
|
|
605
|
+
if kind != "dashboard":
|
|
606
|
+
await downstream.close(code=1008)
|
|
607
|
+
return
|
|
608
|
+
identity = host.access.identity(downstream)
|
|
609
|
+
try:
|
|
610
|
+
initial_actor = await host.access.admit(identity)
|
|
611
|
+
reference = host.access.register(identity)
|
|
612
|
+
except PluginError:
|
|
613
|
+
await downstream.close(code=1008)
|
|
614
|
+
return
|
|
615
|
+
initial_mode = await host.access.mode()
|
|
616
|
+
url = address.replace("http://", "ws://", 1) + "/" + path
|
|
617
|
+
if downstream.url.query:
|
|
618
|
+
url += "?" + downstream.url.query
|
|
619
|
+
protocols = downstream.scope.get("subprotocols", [])
|
|
620
|
+
headers = {
|
|
621
|
+
key: value
|
|
622
|
+
for key, value in downstream.headers.items()
|
|
623
|
+
if key.lower() not in HOP_HEADERS
|
|
624
|
+
and not key.lower().startswith("sec-websocket-")
|
|
625
|
+
and key.lower() not in ("origin", "authorization", "cookie")
|
|
626
|
+
and not key.lower().startswith("x-toolframe-")
|
|
627
|
+
}
|
|
628
|
+
headers[CONTEXT_HEADER] = reference
|
|
629
|
+
if cookies := provider_cookies(downstream):
|
|
630
|
+
headers["cookie"] = cookies
|
|
631
|
+
task = asyncio.current_task()
|
|
632
|
+
host.gateway_sessions.setdefault(runtime.name, set()).add(task)
|
|
633
|
+
try:
|
|
634
|
+
async with connect(
|
|
635
|
+
url,
|
|
636
|
+
origin=address,
|
|
637
|
+
subprotocols=protocols or None,
|
|
638
|
+
additional_headers=headers,
|
|
639
|
+
max_size=200 * 1024 * 1024,
|
|
640
|
+
proxy=None,
|
|
641
|
+
) as upstream:
|
|
642
|
+
await downstream.accept(subprotocol=upstream.subprotocol)
|
|
643
|
+
|
|
644
|
+
async def incoming():
|
|
645
|
+
while True:
|
|
646
|
+
event = await downstream.receive()
|
|
647
|
+
if event["type"] == "websocket.disconnect":
|
|
648
|
+
return
|
|
649
|
+
await upstream.send(
|
|
650
|
+
event.get("text")
|
|
651
|
+
if event.get("text") is not None
|
|
652
|
+
else event["bytes"]
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
async def outgoing():
|
|
656
|
+
async for message in upstream:
|
|
657
|
+
if isinstance(message, str):
|
|
658
|
+
await downstream.send_text(message)
|
|
659
|
+
else:
|
|
660
|
+
await downstream.send_bytes(message)
|
|
661
|
+
|
|
662
|
+
async def session_validity():
|
|
663
|
+
while True:
|
|
664
|
+
await asyncio.sleep(1)
|
|
665
|
+
try:
|
|
666
|
+
await host.access.admit(identity)
|
|
667
|
+
except PluginError:
|
|
668
|
+
return
|
|
669
|
+
if (
|
|
670
|
+
not initial_actor.unrestricted
|
|
671
|
+
and (await host.access.mode()) != initial_mode
|
|
672
|
+
):
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
pumps = [
|
|
676
|
+
asyncio.create_task(incoming()),
|
|
677
|
+
asyncio.create_task(outgoing()),
|
|
678
|
+
asyncio.create_task(session_validity()),
|
|
679
|
+
]
|
|
680
|
+
try:
|
|
681
|
+
done, _ = await asyncio.wait(
|
|
682
|
+
pumps, return_when=asyncio.FIRST_COMPLETED
|
|
683
|
+
)
|
|
684
|
+
for finished in done:
|
|
685
|
+
await finished
|
|
686
|
+
finally:
|
|
687
|
+
for pump in pumps:
|
|
688
|
+
pump.cancel()
|
|
689
|
+
await asyncio.gather(*pumps, return_exceptions=True)
|
|
690
|
+
except ConnectionClosed, asyncio.CancelledError, OSError:
|
|
691
|
+
pass
|
|
692
|
+
finally:
|
|
693
|
+
host.access.contexts.pop(reference, None)
|
|
694
|
+
host.gateway_sessions.get(runtime.name, set()).discard(task)
|
|
695
|
+
with suppress(RuntimeError):
|
|
696
|
+
await downstream.close(code=1012)
|
|
697
|
+
|
|
698
|
+
if getattr(host, "tracing", None):
|
|
699
|
+
app.add_middleware(TracingMiddleware, tracing=host.tracing)
|
|
700
|
+
return app
|