Skip to content

Commit d710e42

Browse files
committed
A2App passthrough and WebSocket paths
1 parent 4eeba5a commit d710e42

2 files changed

Lines changed: 248 additions & 21 deletions

File tree

‎app/agent_app/a2app_proxy.py‎

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,38 @@
44
AS-IS and cannot host the PocketBase adapter hooks, so the A2App surface
55
sits in FRONT of it: the app binds a hidden internal loopback port, this
66
proxy binds the project's assigned port, answers the protocol endpoints
7-
itself, and passes every other request through untouched (the app's own UI
8-
keeps working). Because the proxy is system code running inside CraftBot,
7+
itself, and passes every other request through to the app (the app's own
8+
UI keeps working). Because the proxy is system code running inside CraftBot,
99
"adapter stamped at every launch" holds for externals with no sync step.
1010
1111
Served surface (mirrors the native pb_hooks adapter):
1212
GET /api/_a2app identity (+ flavor:"external")
1313
GET /api/_a2app/describe operations + conventions (entities: {} — the
1414
foreign data model is not mapped; ops only)
1515
GET /api/_ops operations.json verbatim
16-
* /api/ops/{name} guarded invocation, mapped onto the app's API
17-
* anything else transparent passthrough (HTTP + WebSocket)
18-
19-
Auth mirrors _system.pb.js (see `guard_request`). Two independent checks:
20-
the ORIGIN check refuses foreign-origin mutations outright, and the CALLER
21-
check requires every mutation to carry a credential — X-A2App-Token from the
22-
project's .agent-token (programs, the agent), or the UI session cookie the
23-
app's own browser UI is issued. An allowed Origin is never a credential:
24-
tunnel traffic arrives over loopback and can claim any Origin it likes.
25-
Through a tunnel, every request needs the credential, reads included; the
26-
UI session there is only issued in exchange for the share link's secret.
16+
* /api/ops/{name} invocation, mapped onto the app's API
17+
* anything else passthrough to the app (HTTP + WebSocket)
18+
19+
EVERY route, passthrough included, sits behind one guard (`guard_request`,
20+
mirroring _system.pb.js). Two independent checks: the ORIGIN check refuses
21+
foreign-origin mutations outright, and the CALLER check requires every
22+
mutation to carry a credential — X-A2App-Token from the project's
23+
.agent-token (programs, the agent), or the UI session cookie the app's own
24+
browser UI is issued with the HTML page that boots it. An allowed Origin is
25+
never a credential: tunnel traffic arrives over loopback and can claim any
26+
Origin it likes. Locally, reads are open; through a tunnel, every request
27+
needs the credential, reads included, and the UI session there is only
28+
issued in exchange for the share link's secret. A WebSocket handshake is
29+
judged as a mutation (it opens a write channel, and browsers apply no CORS
30+
to it) and refused before upgrading. Declared ops are the SHAPED write path
31+
(typed params, audit log); the app's native API is not a way around the
32+
guard.
33+
34+
CORS is the proxy's policy, not the app's: upstream Access-Control-* headers
35+
are stripped, the grant is reflected only to loopback / the shared origin
36+
(`origin_allowed`), and a foreign origin gets none, so the browser withholds
37+
every response from it.
38+
2739
Ops are (re)read from operations.json on every request, like the native
2840
describe, so the surface can never drift from the file on disk.
2941
"""
@@ -52,6 +64,16 @@
5264
EXTERNAL_ADAPTER_VERSION = "0.1.0"
5365
LOOPBACK_ORIGIN = re.compile(r"^https?://(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$")
5466
MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
67+
# Upstream Access-Control-* kept for ALLOWED origins only (see
68+
# _passthrough_cors); Allow-Origin itself is always the proxy's.
69+
UPSTREAM_CORS_SHAPE = {
70+
"access-control-allow-methods",
71+
"access-control-allow-headers",
72+
"access-control-expose-headers",
73+
"access-control-max-age",
74+
"access-control-allow-credentials",
75+
"access-control-allow-private-network",
76+
}
5577
# Hop-by-hop headers never forwarded in either direction (RFC 9110 §7.6.1).
5678
HOP_HEADERS = {
5779
"connection",
@@ -426,10 +448,15 @@ def _ops_raw(self) -> bytes:
426448
def _origin_allowed(self, origin: str) -> bool:
427449
return origin_allowed(self.project_dir, origin)
428450

429-
def _deny(self, request):
430-
"""guard_request as a response: None to proceed, else the refusal."""
451+
def _deny(self, request, method: Optional[str] = None):
452+
"""guard_request as a response: None to proceed, else the refusal.
453+
`method` overrides the request's (a WebSocket handshake is a GET that
454+
opens a write channel, so it is judged as one)."""
431455
denied = guard_request(
432-
self.project_dir, request.method, request.headers, request.cookies
456+
self.project_dir,
457+
method or request.method,
458+
request.headers,
459+
request.cookies,
433460
)
434461
if denied is None:
435462
return None
@@ -449,7 +476,24 @@ def _reflect_cors(self, request, resp) -> None:
449476
origin = request.headers.get("Origin", "")
450477
if origin and self._origin_allowed(origin):
451478
resp.headers["Access-Control-Allow-Origin"] = origin
452-
resp.headers["Vary"] = "Origin"
479+
vary = resp.headers.get("Vary", "")
480+
if "origin" not in {v.strip().lower() for v in vary.split(",")}:
481+
resp.headers["Vary"] = f"{vary}, Origin" if vary else "Origin"
482+
483+
def _passthrough_cors(self, request, resp, upstream_headers) -> None:
484+
"""The proxy's CORS policy over the app's own responses. WHO may read
485+
is the proxy's call (_reflect_cors), never the app's — an app sending
486+
`Access-Control-Allow-Origin: *` would otherwise hand its data to any
487+
site. HOW (methods, headers, max-age, credentials) describes the
488+
app's API, so an allowed origin still gets the app's answer to that;
489+
a foreign origin gets no Access-Control-* at all."""
490+
origin = request.headers.get("Origin", "")
491+
if not (origin and self._origin_allowed(origin)):
492+
return
493+
self._reflect_cors(request, resp)
494+
for k, v in upstream_headers.items():
495+
if k.lower() in UPSTREAM_CORS_SHAPE:
496+
resp.headers[k] = v
453497

454498
def _log_action(self, entry: Dict[str, Any]) -> None:
455499
try:
@@ -486,8 +530,14 @@ async def _handle(self, request):
486530
return self._ops_manifest(request)
487531
if own:
488532
return await self._invoke(request)
489-
# TODO(passthrough-auth): the app's own surface is not guarded yet;
490-
# the follow-up applies guard_request here too.
533+
# The app's own surface sits under the same guard: declared ops are
534+
# not a guarded write path if the native API beside them is open.
535+
# A WebSocket handshake is refused BEFORE upgrading — browsers apply
536+
# no CORS to it, so this is the only cross-site defence it has.
537+
websocket = request.headers.get("Upgrade", "").lower() == "websocket"
538+
denied = self._deny(request, method="POST" if websocket else None)
539+
if denied is not None:
540+
return denied
491541
return await self._passthrough(request)
492542

493543
def _share_exchange(self, request):
@@ -821,9 +871,12 @@ async def _passthrough(self, request):
821871
allow_redirects=False,
822872
) as up:
823873
resp = web.StreamResponse(status=up.status)
874+
# add(), not assignment: an app may send several Set-Cookie.
824875
for k, v in up.headers.items():
825-
if k.lower() not in HOP_HEADERS:
826-
resp.headers[k] = v
876+
lk = k.lower()
877+
if lk not in HOP_HEADERS and not lk.startswith("access-control-"):
878+
resp.headers.add(k, v)
879+
self._passthrough_cors(request, resp, up.headers)
827880
# The app's own UI gets its session with the page that boots
828881
# it; its same-origin fetches then carry it automatically.
829882
if request.method == "GET" and (up.content_type or "").startswith(
@@ -855,6 +908,8 @@ async def _passthrough(self, request):
855908
)
856909

857910
async def _ws_passthrough(self, request):
911+
# Caller already cleared guard_request (as a mutation) in _handle;
912+
# nothing here may upgrade a request that did not pass through it.
858913
import asyncio
859914

860915
import aiohttp

‎app/agent_app/test_a2app_external.py‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,31 @@ async def wipe(_request):
209209
seen["todos"] = []
210210
return web.json_response({"ok": True})
211211

212+
# The app's native API as a typical adopted codebase ships it: wide-open
213+
# CORS (the thing the proxy must NOT reflect) and more than one cookie.
214+
async def echo(request):
215+
seen.setdefault("echo", []).append(request.method)
216+
resp = web.json_response({"method": request.method})
217+
resp.headers["Access-Control-Allow-Origin"] = "*"
218+
resp.headers["Access-Control-Allow-Credentials"] = "true"
219+
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE"
220+
resp.headers["Access-Control-Allow-Headers"] = "Content-Type, X-Custom"
221+
resp.headers["Vary"] = "Accept-Encoding"
222+
resp.set_cookie("app_a", "1")
223+
resp.set_cookie("app_b", "2")
224+
return resp
225+
226+
async def ws_echo(request):
227+
ws = web.WebSocketResponse()
228+
await ws.prepare(request)
229+
seen.setdefault("ws", []).append(request.headers.get("Origin", ""))
230+
async for msg in ws:
231+
await ws.send_str("echo:" + msg.data)
232+
return ws
233+
212234
app = web.Application()
235+
app.router.add_route("*", "/api/echo", echo)
236+
app.router.add_get("/ws", ws_echo)
213237
app.router.add_get("/", root)
214238
app.router.add_post("/api/todos", create_todo)
215239
app.router.add_get("/api/todos", list_todos)
@@ -376,6 +400,153 @@ async def post(headers, cookie=None, title="x"):
376400
assert titles == ["agent", "agent+origin", "legacy", "ui", "visitor", "remote agent"], titles
377401

378402

403+
async def _passthrough_matrix(http, base: str, tmp: Path, seen) -> None:
404+
"""The app's NATIVE surface (everything not /api/_a2app*, /api/_ops,
405+
/api/ops/*) under the same guard: it used to be passed through with no
406+
Origin or caller check, the app's own CORS reflected verbatim, and its
407+
WebSocket open to any site (browsers apply no CORS to the handshake)."""
408+
import aiohttp
409+
410+
echo = f"{base}/api/echo"
411+
ws_url = f"ws://127.0.0.1:{PROXY_PORT}/ws"
412+
loopback = {"Origin": f"http://127.0.0.1:{PROXY_PORT}"}
413+
evil = {"Origin": "https://evil.example"}
414+
415+
def jar(cookie):
416+
return {"Cookie": f"{cookie[0]}={cookie[1]}"}
417+
418+
async def send(method, headers, url=echo):
419+
before = len(seen.get("echo", []))
420+
async with http.request(method, url, json={"t": 1}, headers=headers) as r:
421+
body = await r.json()
422+
reached = len(seen.get("echo", [])) > before
423+
return r.status, body, reached
424+
425+
async def ws(headers):
426+
"""(status, echoed) — status 101 on upgrade, else the refusal."""
427+
try:
428+
async with http.ws_connect(ws_url, headers=headers) as w:
429+
await w.send_str("hi")
430+
msg = await w.receive(timeout=5)
431+
return 101, msg.data
432+
except aiohttp.WSServerHandshakeError as e:
433+
return e.status, None
434+
435+
async with http.get(f"{base}/") as r:
436+
local = _set_cookie(r)
437+
438+
# ── HTTP writes: foreign origins refused, callers must hold a credential
439+
for method in ("POST", "PUT", "PATCH", "DELETE"):
440+
status, body, reached = await send(method, evil)
441+
assert status == 403 and body["code"] == "forbidden_origin", (method, status)
442+
assert not reached, f"foreign-origin {method} reached the app"
443+
status, _, reached = await send("POST", {**evil, "X-A2App-Token": TOKEN})
444+
assert status == 403 and not reached, "a token does not launder a foreign origin"
445+
status, _, reached = await send("POST", {**evil, **jar(local)})
446+
assert status == 403 and not reached, "a session does not launder a foreign origin"
447+
for label, headers in (
448+
("no Origin, no credential", {}),
449+
("loopback Origin, no credential", loopback),
450+
("wrong token", {"X-A2App-Token": "nope"}),
451+
):
452+
status, body, reached = await send("POST", headers)
453+
assert status == 401 and body["code"] == "unauthorized", (label, status)
454+
assert not reached, label
455+
async with http.post(f"{base}/api/todos", json={"title": "evil"}, headers=evil) as r:
456+
assert r.status == 403, "a real app route, not just the echo"
457+
458+
# the app's own UI (loopback page + the session its HTML page issued)
459+
for method in ("POST", "PUT", "PATCH", "DELETE"):
460+
status, body, reached = await send(method, {**loopback, **jar(local)})
461+
assert status == 200 and body["method"] == method and reached, method
462+
# the agent / a local program
463+
status, _, reached = await send("POST", {"X-A2App-Token": TOKEN})
464+
assert status == 200 and reached
465+
# local reads stay open (CORS, below, keeps them from foreign pages)
466+
status, _, reached = await send("GET", {})
467+
assert status == 200 and reached
468+
469+
# ── CORS: the proxy's policy, never the app's `*` ──
470+
async with http.get(echo, headers=evil) as r:
471+
acx = [k for k in r.headers if k.lower().startswith("access-control-")]
472+
assert r.status == 200 and not acx, f"upstream CORS reflected: {acx}"
473+
other_local = {"Origin": "http://localhost:5173"}
474+
async with http.get(echo, headers=other_local) as r:
475+
assert r.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"
476+
assert r.headers["Access-Control-Allow-Credentials"] == "true"
477+
vary = {v.strip() for v in r.headers["Vary"].split(",")}
478+
assert vary == {"Accept-Encoding", "Origin"}, vary
479+
cookies = {c.split("=", 1)[0] for c in r.headers.getall("Set-Cookie")}
480+
assert {"app_a", "app_b"} <= cookies, "every upstream Set-Cookie survives"
481+
preflight = {"Access-Control-Request-Method": "PUT"}
482+
async with http.options(echo, headers={**evil, **preflight}) as r:
483+
acx = [k for k in r.headers if k.lower().startswith("access-control-")]
484+
assert not acx, f"foreign preflight granted: {acx}"
485+
async with http.options(echo, headers={**other_local, **preflight}) as r:
486+
assert r.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"
487+
assert "PUT" in r.headers["Access-Control-Allow-Methods"]
488+
assert r.headers["Access-Control-Allow-Headers"] == "Content-Type, X-Custom"
489+
490+
# ── WebSocket: judged at the handshake, refused before upgrading ──
491+
ws_seen = len(seen.get("ws", []))
492+
for label, headers, want in (
493+
("foreign Origin", evil, 403),
494+
("foreign Origin + token", {**evil, "X-A2App-Token": TOKEN}, 403),
495+
("foreign Origin + session", {**evil, **jar(local)}, 403),
496+
("no Origin, no credential", {}, 401),
497+
("loopback Origin, no credential", loopback, 401),
498+
):
499+
status, _ = await ws(headers)
500+
assert status == want, (label, status)
501+
assert len(seen.get("ws", [])) == ws_seen, "a refused handshake reached the app"
502+
assert await ws({**loopback, **jar(local)}) == (101, "echo:hi"), "the app's own UI"
503+
assert await ws({"X-A2App-Token": TOKEN}) == (101, "echo:hi"), "the agent"
504+
505+
# ── through the tunnel ──
506+
secret = "share-secret-for-passthrough-0123456789"
507+
(tmp / ".tunnel-origin").write_text(SHARED, encoding="utf-8")
508+
(tmp / ".tunnel-secret").write_text(secret, encoding="utf-8")
509+
try:
510+
async with http.get(f"{base}/", headers=VIA_TUNNEL) as r:
511+
body = await r.json()
512+
assert r.status == 401 and body["code"] == "share_session_required", (
513+
"a bare tunnel URL must not read the app"
514+
)
515+
async with http.get(
516+
f"{base}/?a2app_share={secret}", headers=VIA_TUNNEL, allow_redirects=False
517+
) as r:
518+
assert r.status == 302
519+
shared = _set_cookie(r)
520+
visitor = {**VIA_TUNNEL, "Origin": SHARED, **jar(shared)}
521+
522+
async with http.get(f"{base}/", headers={**VIA_TUNNEL, **jar(shared)}) as r:
523+
assert r.status == 200 and (await r.text()) == "UPSTREAM OK"
524+
status, _, reached = await send("POST", visitor)
525+
assert status == 200 and reached, "the shared UI writes"
526+
async with http.get(echo, headers=visitor) as r:
527+
assert r.headers["Access-Control-Allow-Origin"] == SHARED
528+
assert await ws(visitor) == (101, "echo:hi"), "the shared UI's WebSocket"
529+
530+
for label, headers, want in (
531+
("no session", {**VIA_TUNNEL, "Origin": SHARED}, 401),
532+
("forged loopback Origin", {**VIA_TUNNEL, **loopback}, 401),
533+
("local session", {**VIA_TUNNEL, "Origin": SHARED, **jar(local)}, 401),
534+
("foreign Origin + session", {**VIA_TUNNEL, **evil, **jar(shared)}, 403),
535+
):
536+
status, _, reached = await send("POST", headers)
537+
assert status == want and not reached, ("http", label, status)
538+
status, _ = await ws(headers)
539+
assert status == want, ("ws", label, status)
540+
status, _, reached = await send("POST", {**VIA_TUNNEL, "X-A2App-Token": TOKEN})
541+
assert status == 200 and reached, "a remote agent with the token"
542+
finally:
543+
(tmp / ".tunnel-secret").unlink(missing_ok=True)
544+
(tmp / ".tunnel-origin").unlink(missing_ok=True)
545+
# Let the proxy finish closing its upstream WebSockets while this loop
546+
# (which also runs the upstream) is still free to answer them.
547+
await asyncio.sleep(0.3)
548+
549+
379550
async def _proxy_suite(tmp: Path) -> None:
380551
import aiohttp
381552

@@ -449,6 +620,7 @@ async def _proxy_suite(tmp: Path) -> None:
449620
assert r.status == 200 and (await r.json())["title"] == "call John"
450621

451622
await _auth_matrix(http, base, tmp, seen)
623+
await _passthrough_matrix(http, base, tmp, seen)
452624

453625
# unknown op -> 404 envelope, never a silent passthrough
454626
async with http.post(f"{base}/api/ops/nope", json={}, headers=auth) as r:

0 commit comments

Comments
 (0)