Files
openworker/coworker/remote/cloudproxy.py
Rohit C Prasad 274c9a582b Remote machines: identity, reverse channel, acceptor, sealed key wallet
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.
2026-09-05 08:00:16 -07:00

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