mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 21:37:21 +00:00
A machine joins with its own identity and holds a reverse channel to the controller. Provider keys and connector accounts deploy sealed to the machine's pinned key.
297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""Union view: the desktop sidecar's window onto a hosted cloud registry.
|
|
|
|
The desktop SPA talks only to its own sidecar; this module gives that sidecar
|
|
a `/v1/cloud/machines*` surface that forwards to the hosted machines service
|
|
with the user's cloud session token. One backend for the SPA, no CORS, and
|
|
the token never enters the webview. The two registries stay separate — this
|
|
is a VIEW, never a merge (spec: "Union view on the signed-in desktop").
|
|
|
|
Design rule enforced here: a cloud-session problem degrades the cloud
|
|
section, it never errors the local path. Signed out or expired comes back as
|
|
a normal 200 with a `session` state the GUI renders; only true network
|
|
failures surface as 502.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from typing import Any, Awaitable, Callable, Optional
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import JSONResponse, Response
|
|
|
|
from .identity import seal_b64
|
|
|
|
# "signed_out" = no cloud profile at all; "expired" = a profile exists but the
|
|
# session could not be refreshed (Auth0 refresh tokens die from inactivity);
|
|
# "ok" = a usable token was produced. The GUI hides the cloud section on
|
|
# signed_out and shows a sign-in-again row on expired.
|
|
TokenProvider = Callable[[], Awaitable[tuple[str, Optional[str]]]]
|
|
|
|
_ME_TTL_SECONDS = 900.0
|
|
|
|
|
|
def mount_cloud_proxy(
|
|
app: FastAPI,
|
|
*,
|
|
token_provider: TokenProvider,
|
|
base_url: str,
|
|
wallet=None,
|
|
ws_authenticated=None,
|
|
origin_allowed=None,
|
|
client: Optional[httpx.AsyncClient] = None,
|
|
) -> None:
|
|
"""`token_provider` returns (state, token) — state per the notes above,
|
|
token only when state == "ok". `wallet` is the desktop SecretStore the
|
|
wallet-send deploy path resolves names from (values are sealed HERE, to
|
|
the machine's pinned key — the cloud only ever relays ciphertext).
|
|
`client` is injectable for tests (httpx.MockTransport)."""
|
|
|
|
upstream = client or httpx.AsyncClient(base_url=base_url, timeout=30)
|
|
me_cache: dict[str, Any] = {"at": 0.0, "org": None}
|
|
|
|
async def _forward(
|
|
method: str,
|
|
path: str,
|
|
token: str,
|
|
*,
|
|
body: Optional[bytes] = None,
|
|
content_type: Optional[str] = None,
|
|
query: str = "",
|
|
) -> Response:
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
if content_type:
|
|
headers["Content-Type"] = content_type
|
|
try:
|
|
resp = await upstream.request(
|
|
method, path + (f"?{query}" if query else ""), content=body, headers=headers
|
|
)
|
|
except httpx.HTTPError:
|
|
return JSONResponse({"error": "cloud unreachable"}, status_code=502)
|
|
return Response(
|
|
content=resp.content,
|
|
status_code=resp.status_code,
|
|
media_type=resp.headers.get("content-type"),
|
|
)
|
|
|
|
async def _forward_request(path: str, request: Request) -> Response:
|
|
state, token = await token_provider()
|
|
if state != "ok":
|
|
return JSONResponse({"error": "cloud_session", "session": state}, status_code=401)
|
|
return await _forward(
|
|
request.method,
|
|
path,
|
|
token or "",
|
|
body=await request.body() or None,
|
|
content_type=request.headers.get("content-type"),
|
|
query=request.url.query,
|
|
)
|
|
|
|
async def _org(token: str) -> Optional[dict[str, str]]:
|
|
if time.monotonic() - me_cache["at"] < _ME_TTL_SECONDS and me_cache["org"]:
|
|
return me_cache["org"]
|
|
try:
|
|
resp = await upstream.get(
|
|
"/v1/me", headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
if resp.status_code != 200:
|
|
return me_cache["org"]
|
|
me = resp.json()
|
|
row = next(
|
|
(o for o in me.get("orgs", []) if o.get("id") == me.get("org_id")),
|
|
None,
|
|
)
|
|
me_cache["org"] = {
|
|
"id": me.get("org_id", ""),
|
|
"name": (row or {}).get("name") or me.get("org_id", ""),
|
|
}
|
|
me_cache["at"] = time.monotonic()
|
|
except httpx.HTTPError:
|
|
pass
|
|
return me_cache["org"]
|
|
|
|
@app.get("/v1/cloud/machines")
|
|
async def cloud_machines() -> Any:
|
|
state, token = await token_provider()
|
|
if state != "ok":
|
|
return {"session": state, "machines": []}
|
|
try:
|
|
resp = await upstream.get(
|
|
"/v1/machines", headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
except httpx.HTTPError:
|
|
return {"session": "unreachable", "machines": []}
|
|
if resp.status_code == 401:
|
|
# The token minted fine but the service refused it — treat like
|
|
# an expired session rather than a hard error.
|
|
return {"session": "expired", "machines": []}
|
|
if resp.status_code != 200:
|
|
return {"session": "unreachable", "machines": []}
|
|
rows = resp.json().get("machines", [])
|
|
return {"session": "ok", "org": await _org(token or ""), "machines": rows}
|
|
|
|
@app.api_route("/v1/cloud/machines/{machine_id}", methods=["PATCH", "DELETE"])
|
|
async def cloud_machine_admin(machine_id: str, request: Request) -> Response:
|
|
return await _forward_request(f"/v1/machines/{machine_id}", request)
|
|
|
|
@app.get("/v1/cloud/machines/{machine_id}/sessions")
|
|
async def cloud_machine_sessions(machine_id: str, request: Request) -> Response:
|
|
return await _forward_request(f"/v1/machines/{machine_id}/sessions", request)
|
|
|
|
@app.get("/v1/cloud/machines/{machine_id}/sessions/{session_id}/messages")
|
|
async def cloud_machine_transcript(
|
|
machine_id: str, session_id: str, request: Request
|
|
) -> Response:
|
|
return await _forward_request(
|
|
f"/v1/machines/{machine_id}/sessions/{session_id}/messages", request
|
|
)
|
|
|
|
@app.api_route("/v1/cloud/machines/{machine_id}/secrets", methods=["GET", "DELETE"])
|
|
async def cloud_machine_secrets(machine_id: str, request: Request) -> Response:
|
|
return await _forward_request(f"/v1/machines/{machine_id}/secrets", request)
|
|
|
|
@app.post("/v1/cloud/machines/{machine_id}/secrets")
|
|
async def cloud_deploy_secrets(machine_id: str, request: Request) -> Response:
|
|
"""Wallet-send to a cloud machine (the OPE-149 increment the union
|
|
view unlocks): the GUI sends NAMES; values resolve from the desktop
|
|
wallet and are sealed HERE to the machine's pinned key. The cloud
|
|
endpoint receives only the sealed blob — the same blind-relay path
|
|
the browser uses, with wallet-hash provenance in the ledger."""
|
|
state, token = await token_provider()
|
|
if state != "ok":
|
|
return JSONResponse({"error": "cloud_session", "session": state}, status_code=401)
|
|
payload = await request.json()
|
|
names = [str(n) for n in (payload.get("profiles") or []) if str(n).strip()]
|
|
if not names:
|
|
return JSONResponse({"error": "profiles required"}, status_code=400)
|
|
if wallet is None:
|
|
return JSONResponse({"error": "no wallet configured"}, status_code=500)
|
|
try:
|
|
resp = await upstream.get(
|
|
"/v1/machines", headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
rows = resp.json().get("machines", []) if resp.status_code == 200 else []
|
|
except httpx.HTTPError:
|
|
return JSONResponse({"error": "cloud unreachable"}, status_code=502)
|
|
row = next((m for m in rows if m.get("id") == machine_id), None)
|
|
if row is None:
|
|
return JSONResponse({"error": "unknown machine"}, status_code=404)
|
|
seal_pubkey = row.get("seal_pubkey") or ""
|
|
if not seal_pubkey:
|
|
return JSONResponse(
|
|
{"error": "machine has no pinned sealing key yet"}, status_code=409
|
|
)
|
|
profiles: dict[str, Any] = {}
|
|
for name in names:
|
|
data = wallet.get(name)
|
|
if data is None:
|
|
return JSONResponse(
|
|
{"error": f"profile '{name}' not in the wallet"}, status_code=400
|
|
)
|
|
profiles[name] = data
|
|
from .acceptor import _hash_profile
|
|
|
|
sealed = seal_b64(seal_pubkey, json.dumps({"profiles": profiles}).encode())
|
|
body = {
|
|
"sealed_b64": sealed,
|
|
"profiles": names,
|
|
"hashes": {name: _hash_profile(data) for name, data in profiles.items()},
|
|
}
|
|
return await _forward(
|
|
"POST",
|
|
f"/v1/machines/{machine_id}/secrets",
|
|
token or "",
|
|
body=json.dumps(body).encode(),
|
|
content_type="application/json",
|
|
)
|
|
|
|
@app.post("/v1/cloud/machines/{machine_id}/connectors/{name}/connect-sealed")
|
|
async def cloud_connector_connect_sealed(
|
|
machine_id: str, name: str, request: Request
|
|
) -> Response:
|
|
# Already ciphertext (sealed in the GUI to the machine's pinned key) —
|
|
# a plain forward; the hosted service relays it blind like key deploys.
|
|
return await _forward_request(
|
|
f"/v1/machines/{machine_id}/connectors/{name}/connect-sealed", request
|
|
)
|
|
|
|
@app.api_route(
|
|
"/v1/cloud/machines/{machine_id}/p/{path:path}",
|
|
methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
)
|
|
async def cloud_proxy(machine_id: str, path: str, request: Request) -> Response:
|
|
return await _forward_request(f"/v1/machines/{machine_id}/p/{path}", request)
|
|
|
|
@app.websocket("/ws/cloud/machines/{machine_id}/p/{path:path}")
|
|
async def cloud_ws_bridge(ws: WebSocket, machine_id: str, path: str) -> None:
|
|
"""Bridged session socket for a cloud machine: the sidecar dials the
|
|
hosted service's own bridge and pipes frames both ways. Gated like
|
|
every GUI-facing socket (sidecar token + Origin)."""
|
|
if ws_authenticated is not None and not ws_authenticated(ws):
|
|
await ws.close(code=1008)
|
|
return
|
|
if origin_allowed is not None and not origin_allowed(ws.headers.get("origin")):
|
|
await ws.close(code=1008)
|
|
return
|
|
state, token = await token_provider()
|
|
if state != "ok":
|
|
await ws.close(code=1008)
|
|
return
|
|
import websockets
|
|
|
|
ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://")
|
|
target = f"{ws_base}/ws/machines/{machine_id}/p/{path}"
|
|
query = ws.scope.get("query_string", b"").decode()
|
|
if query:
|
|
target += "?" + query
|
|
offered = {
|
|
part.strip()
|
|
for part in ws.headers.get("sec-websocket-protocol", "").split(",")
|
|
if part.strip()
|
|
}
|
|
try:
|
|
upstream_ws = await websockets.connect(
|
|
target,
|
|
additional_headers={"Authorization": f"Bearer {token}"},
|
|
subprotocols=["openworker"],
|
|
max_size=16 * 1024 * 1024,
|
|
)
|
|
except Exception:
|
|
await ws.close(code=1013) # cloud or machine unreachable: retry later
|
|
return
|
|
await ws.accept(subprotocol="openworker" if "openworker" in offered else None)
|
|
|
|
async def pump_down() -> None:
|
|
try:
|
|
async for frame in upstream_ws:
|
|
if isinstance(frame, str):
|
|
await ws.send_text(frame)
|
|
finally:
|
|
# Upstream ended: close the GUI side too, or its receive loop
|
|
# would sit open on a dead bridge.
|
|
try:
|
|
await ws.close()
|
|
except Exception:
|
|
pass
|
|
|
|
down = asyncio.create_task(pump_down())
|
|
try:
|
|
while True:
|
|
text = await ws.receive_text()
|
|
await upstream_ws.send(text)
|
|
except (WebSocketDisconnect, Exception):
|
|
pass
|
|
finally:
|
|
down.cancel()
|
|
try:
|
|
await upstream_ws.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await ws.close()
|
|
except Exception:
|
|
pass
|