From 58da2a1959a9093cca186f56c933f6aff14495f5 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Mon, 21 Sep 2026 14:56:18 +0100 Subject: [PATCH 1/5] fix: A2App agent-token gate is bypassed by sending any Origin header --- agent-app/blueprint/pb/pb_hooks/_a2app_lib.js | 208 +++++++++++ agent-app/blueprint/pb/pb_hooks/_system.pb.js | 74 +--- app/agent_app/a2app_proxy.py | 340 ++++++++++++++---- app/agent_app/manager.py | 61 +++- app/agent_app/test_a2app_external.py | 174 +++++++-- app/agent_app/test_a2app_native_auth.py | 280 +++++++++++++++ app/agent_app/test_tunnel.py | 19 +- app/ui_layer/adapters/browser_adapter.py | 7 +- 8 files changed, 1003 insertions(+), 160 deletions(-) create mode 100644 app/agent_app/test_a2app_native_auth.py diff --git a/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js b/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js index 285c535d..11166536 100644 --- a/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js +++ b/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js @@ -505,8 +505,216 @@ function schemaVersion(app) { return 'sv_' + h.toString(16); } +/* ------------------------------------------------------ caller auth + * Mirrors guard_request in CraftBot's app/agent_app/a2app_proxy.py — same + * rules, same derived values ($security.hs256(text, secret) is the same + * HMAC-SHA256-hex the proxy computes). Two INDEPENDENT checks: the origin + * guard in _system.pb.js decides which browser pages may talk to the app; + * this decides who the caller is. An allowed Origin is never a credential — + * tunnel traffic arrives over loopback and can claim any Origin it likes. + * + * Credentials: the agent token (programs), the UI session cookie (the app's + * own frontend; issued on the page load locally, and ONLY in exchange for + * the share link's secret through the tunnel), or a signed-in PocketBase + * principal. Through the tunnel every request needs one, reads included. + */ + +var TUNNEL_MARKER_HEADERS = [ + 'Cf-Ray', + 'Cf-Connecting-Ip', + 'Cf-Visitor', + 'Cdn-Loop', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'Forwarded', +]; +var LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i; +var SHARE_PARAM = 'a2app_share'; + +function readProjectSecret(name) { + try { + return toString($os.readFile($filepath.join(__hooks, '..', '..', name))).trim(); + } catch { + return ''; + } +} + +function headerOf(e, name) { + try { + return String(e.request.header.get(name) || ''); + } catch { + return ''; + } +} + +/** Cloudflare stamps every forwarded request with headers a remote caller + * cannot strip; a local caller faking one only demotes itself. Go keeps the + * Host header in request.host, not in the header map. */ +function isTunnelRequest(e) { + for (var i = 0; i < TUNNEL_MARKER_HEADERS.length; i++) { + if (headerOf(e, TUNNEL_MARKER_HEADERS[i]) !== '') return true; + } + var host = ''; + try { + host = String(e.request.host || ''); + } catch { + host = ''; + } + return !LOOPBACK_HOST.test(host.trim()); +} + +// Per-app name: every app on 127.0.0.1 shares one cookie jar (ports ignored). +function sessionCookieName(token) { + return 'a2app_s_' + $security.hs256('a2app-ui:cookie-name:v1', token).slice(0, 12); +} + +// Stateless: rotating the agent token ends every session; the tunnel value +// folds in .tunnel-secret, which stop_tunnel deletes. +function sessionValue(token, tunnel) { + if (token === '') return ''; + if (!tunnel) return $security.hs256('a2app-ui:local:v1', token); + var secret = readProjectSecret('.tunnel-secret'); + if (secret === '') return ''; + return $security.hs256('a2app-ui:share:v1:' + secret, token); +} + +// Lax, not Strict: a share link opened from chat is a cross-site navigation, +// and Strict would withhold the cookie on the redirect after the exchange. +function sessionCookieHeader(name, value, tunnel) { + return name + '=' + value + '; Path=/; HttpOnly; SameSite=Lax' + (tunnel ? '; Secure' : ''); +} + +function cookieOf(e, name) { + var raw = headerOf(e, 'Cookie'); + var parts = raw.split(';'); + for (var i = 0; i < parts.length; i++) { + var kv = parts[i].trim(); + var eq = kv.indexOf('='); + if (eq > 0 && kv.slice(0, eq) === name) return kv.slice(eq + 1); + } + return ''; +} + +function hasPrincipal(e) { + try { + if (e.auth) return true; + } catch { + /* fall through */ + } + return false; +} + +/** + * THE caller guard. Returns null to proceed, else {status, body}. + * Local mutations on /api/collections/ and /api/ops/ need a credential + * (PocketBase's own sign-in flows stay open); through the tunnel, every + * request does. The origin half lives in _system.pb.js's first routerUse. + */ +function authorizeCaller(e) { + var method = ''; + var path = ''; + try { + method = String(e.request.method || '').toUpperCase(); + path = String((e.request.url && e.request.url.path) || ''); + } catch { + return { status: 400, body: { ok: false, error: 'unreadable request' } }; + } + if (method === 'OPTIONS') return null; // preflights never carry credentials + var tunnel = isTunnelRequest(e); + var mutating = method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE'; + if (!tunnel) { + if (!mutating) return null; + if (path.indexOf('/api/collections/') !== 0 && path.indexOf('/api/ops/') !== 0) return null; + if (path.indexOf('/auth-') > 0 || path.indexOf('/request-') > 0) return null; + } + + var token = readProjectSecret('.agent-token'); + if (token === '') return null; // no token provisioned — do not lock the app out + + // TODO(lui-compat): also accept the legacy X-LUI-Token from older clients. + var presented = (headerOf(e, 'X-A2App-Token') || headerOf(e, 'X-LUI-Token')).trim(); + if (presented !== '' && $security.equal(presented, token)) return null; + var session = sessionValue(token, tunnel); + var cookie = cookieOf(e, sessionCookieName(token)); + if (session !== '' && cookie !== '' && $security.equal(cookie, session)) return null; + if (hasPrincipal(e)) return null; + + if (tunnel) { + return { + status: 401, + body: { + ok: false, + code: 'share_session_required', + error: 'This app is shared by link. Open the full share link you were given (it carries ?a2app_share=...).', + }, + }; + } + return { + status: 401, + body: { + ok: false, + code: 'unauthorized', + error: 'agent token required', + hint: 'Send X-A2App-Token: on writes.', + }, + }; +} + +/** + * Share-link exchange: GET ?a2app_share= through the tunnel + * trades the secret for the tunnel UI session and redirects to the same URL + * without it. Returns true when it answered the request. + */ +function handleShareExchange(e) { + var method = ''; + var presented = ''; + try { + method = String(e.request.method || '').toUpperCase(); + presented = String(e.request.url.query().get(SHARE_PARAM) || ''); + } catch { + return false; + } + if (method !== 'GET' || presented === '' || !isTunnelRequest(e)) return false; + + var secret = readProjectSecret('.tunnel-secret'); + if (secret === '' || !$security.equal(presented, secret)) { + e.json(403, { + ok: false, + code: 'share_link_invalid', + error: 'This share link is invalid or has expired. Ask the owner for a fresh one.', + }); + return true; + } + var headers = e.response.header(); + var token = readProjectSecret('.agent-token'); + var value = sessionValue(token, true); + if (value !== '') headers.add('Set-Cookie', sessionCookieHeader(sessionCookieName(token), value, true)); + headers.set('Cache-Control', 'no-store'); + var q = e.request.url.query(); + q.del(SHARE_PARAM); + var rest = q.encode(); + e.redirect(302, String(e.request.url.path || '/') + (rest ? '?' + rest : '')); + return true; +} + +/** Local ingress only: hand the app's own UI its session with the page that + * boots it (the kit's same-origin fetches then carry it). Anyone who can + * reach loopback gets one — everyone who could already read .agent-token. */ +function issueLocalSession(e) { + if (isTunnelRequest(e)) return; + var token = readProjectSecret('.agent-token'); + var value = sessionValue(token, false); + if (value === '') return; + var name = sessionCookieName(token); + if (cookieOf(e, name) === value) return; + e.response.header().add('Set-Cookie', sessionCookieHeader(name, value, false)); +} + module.exports = { ADAPTER_VERSION: ADAPTER_VERSION, + authorizeCaller: authorizeCaller, + handleShareExchange: handleShareExchange, + issueLocalSession: issueLocalSession, describeApp: describeApp, fieldsOf: fieldsOf, protocolType: protocolType, diff --git a/agent-app/blueprint/pb/pb_hooks/_system.pb.js b/agent-app/blueprint/pb/pb_hooks/_system.pb.js index fe9c1497..245e4758 100644 --- a/agent-app/blueprint/pb/pb_hooks/_system.pb.js +++ b/agent-app/blueprint/pb/pb_hooks/_system.pb.js @@ -100,6 +100,9 @@ routerUse((e) => { (reqPath === '/' || reqPath.indexOf('.') === -1 || /\.html?$/i.test(reqPath)) ) { headers.set('Cache-Control', 'no-store'); + // The page that boots the UI hands it its session (local ingress only); + // see CALLER AUTH below. + require(`${__hooks}/_a2app_lib.js`).issueLocalSession(e); } if (origin === '') return e.next(); // not a browser cross-origin request @@ -202,66 +205,27 @@ routerUse((e) => { }); /** - * AGENT TOKEN (spec A2APP-PLAN Phase 2 C4). + * CALLER AUTH (spec A2APP-PLAN Phase 2 C4) — logic in _a2app_lib.js + * (authorizeCaller), shared rule-for-rule with CraftBot's external-app proxy. * - * A non-browser client that writes must present the project's agent token. - * The app's own frontend does not need it: browsers always send `Origin` on a - * write, and a loopback `Origin` is already trusted by the guard above. So the - * rule is precisely "programmatic callers carry a credential", which is what - * makes handing access to a third-party agent a deliberate act. + * Every write carries a credential, whatever its Origin: the agent token + * (programs), the UI session cookie (the app's own frontend — issued with the + * page locally, and only for the share link's secret through a tunnel), or a + * signed-in principal. The origin guard above is a SEPARATE check: it decides + * which pages may talk to the app, never who the caller is. Trusting a + * loopback `Origin` as a credential was a bypass — tunnel traffic arrives + * over loopback and can send any Origin it likes. * * Not a defence against local processes — anything running as this user can - * read the 0600 file. That is the correct model for a loopback app (Home - * Assistant and Obsidian's local API work the same way); what it buys is a - * real credential to hand out, and the precondition for tightening collection - * rules and for remote access later. + * read the 0600 token file. That is the correct model for a loopback app + * (Home Assistant and Obsidian's local API work the same way); what it buys is + * a real credential to hand out, and a tunnel that only admits link holders. */ routerUse((e) => { - var method = ''; - var path = ''; - var origin = ''; - try { - method = String(e.request.method || '').toUpperCase(); - path = String((e.request.url && e.request.url.path) || ''); - origin = String(e.request.header.get('Origin') || ''); - } catch { - return e.next(); - } - if (method !== 'POST' && method !== 'PATCH' && method !== 'PUT' && method !== 'DELETE') { - return e.next(); - } - if (path.indexOf('/api/collections/') !== 0 && path.indexOf('/api/ops/') !== 0) { - return e.next(); - } - // Browser traffic: already constrained to loopback origins by the guard. - if (origin !== '') return e.next(); - // PocketBase's own auth flows must stay reachable (sign-in, refresh). - if (path.indexOf('/auth-') > 0 || path.indexOf('/request-') > 0) return e.next(); - - var expected = ''; - try { - expected = toString($os.readFile($filepath.join(__hooks, '..', '..', '.agent-token'))).trim(); - } catch { - expected = ''; - } - if (expected === '') return e.next(); // no token provisioned — do not lock the app out - - var presented = ''; - try { - // TODO(lui-compat): also accept the legacy X-LUI-Token from older clients. - presented = String( - e.request.header.get('X-A2App-Token') || e.request.header.get('X-LUI-Token') || '', - ).trim(); - } catch { - presented = ''; - } - if (presented !== expected) { - return e.json(401, { - ok: false, - error: 'agent token required', - hint: 'Send X-A2App-Token: on writes.', - }); - } + const a2 = require(`${__hooks}/_a2app_lib.js`); + if (a2.handleShareExchange(e)) return; + const denied = a2.authorizeCaller(e); + if (denied) return e.json(denied.status, denied.body); return e.next(); }); diff --git a/app/agent_app/a2app_proxy.py b/app/agent_app/a2app_proxy.py index 33c03470..5d00b789 100644 --- a/app/agent_app/a2app_proxy.py +++ b/app/agent_app/a2app_proxy.py @@ -16,18 +16,25 @@ * /api/ops/{name} guarded invocation, mapped onto the app's API * anything else transparent passthrough (HTTP + WebSocket) -Auth mirrors _system.pb.js: browser writes are constrained to loopback -origins; programmatic writes (no Origin) present X-A2App-Token from the -project's .agent-token; foreign-origin mutations are refused outright. +Auth mirrors _system.pb.js (see `guard_request`). Two independent checks: +the ORIGIN check refuses foreign-origin mutations outright, and the CALLER +check requires every mutation to carry a credential — X-A2App-Token from the +project's .agent-token (programs, the agent), or the UI session cookie the +app's own browser UI is issued. An allowed Origin is never a credential: +tunnel traffic arrives over loopback and can claim any Origin it likes. +Through a tunnel, every request needs the credential, reads included; the +UI session there is only issued in exchange for the share link's secret. Ops are (re)read from operations.json on every request, like the native describe, so the surface can never drift from the file on disk. """ +import hashlib +import hmac import json import re from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Mapping, Optional, Tuple from urllib.parse import quote, urlencode try: @@ -60,6 +67,170 @@ UPSTREAM_BODY_CAP = 10 * 1024 * 1024 # ops responses are read whole; cap them EXCERPT = 2000 +# ── caller authentication (shared with the native guard in _a2app_lib.js) ── +# +# Local vs tunnel cannot be told apart by Origin (a tunnelled caller can send +# a loopback one) nor by peer address (cloudflared connects from loopback). +# It CAN be told apart by what Cloudflare adds to every request it forwards — +# headers a remote caller cannot strip. The test is fail-safe: a local caller +# that fakes one only demotes itself to tunnel rules. +TUNNEL_MARKER_HEADERS = ( + "cf-ray", + "cf-connecting-ip", + "cf-visitor", + "cdn-loop", + "x-forwarded-for", + "x-forwarded-host", + "forwarded", +) +LOOPBACK_HOST = re.compile(r"^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$", re.I) +SHARE_PARAM = "a2app_share" +TOKEN_HEADERS = ("X-A2App-Token", "X-LUI-Token") # TODO(lui-compat): legacy + + +def _hs256(secret: str, text: str) -> str: + """HMAC-SHA256 hex — the same construction as PocketBase's + $security.hs256(text, secret), so both adapters derive identical values.""" + return hmac.new(secret.encode(), text.encode(), hashlib.sha256).hexdigest() + + +def _read_secret(project_dir: Path, name: str) -> str: + try: + return (Path(project_dir) / name).read_text(encoding="utf-8").strip() + except Exception: + return "" + + +def origin_allowed(project_dir: Path, origin: str) -> bool: + """Loopback, or the one public origin the host is currently sharing. + + AgentAppManager.start_tunnel writes `.tunnel-origin` and stop_tunnel + deletes it, so the grant lasts exactly as long as the tunnel. Read per + request for the same reason the native guard does: sharing starts and + stops without restarting anything. Loopback-only was not a safe default + for a shared app, it was a broken one — browsers send `Origin` on + same-origin writes too, so through a tunnel every write was refused. + This decides which browser pages may TALK to the app; it authenticates + nobody (see guard_request). + """ + if LOOPBACK_ORIGIN.match(origin): + return True + shared = _read_secret(project_dir, ".tunnel-origin") + return bool(shared) and origin.lower() == shared.lower() + + +def is_tunnel_request(headers: Mapping[str, str]) -> bool: + """True when the request came in through the share tunnel (or cannot be + proven local). Local = no forwarding marker AND a loopback Host.""" + lowered = {k.lower() for k in headers.keys()} + if any(h in lowered for h in TUNNEL_MARKER_HEADERS): + return True + host = next((v for k, v in headers.items() if k.lower() == "host"), "") + return not LOOPBACK_HOST.match(host.strip()) + + +def session_cookie_name(agent_token: str) -> str: + """Per-app name: every app on 127.0.0.1 shares ONE cookie jar (cookies + ignore ports), so a fixed name would have apps overwrite each other.""" + return "a2app_s_" + _hs256(agent_token, "a2app-ui:cookie-name:v1")[:12] + + +def session_value(project_dir: Path, agent_token: str, tunnel: bool) -> str: + """The UI session credential for this ingress, or "" if none can exist. + + Stateless (derived, never stored): rotating the agent token ends every + session. Local and tunnel values differ, so a local cookie is never a + tunnel credential; the tunnel value folds in `.tunnel-secret`, which + stop_tunnel deletes — every shared session dies with the tunnel.""" + if not agent_token: + return "" + if not tunnel: + return _hs256(agent_token, "a2app-ui:local:v1") + secret = _read_secret(project_dir, ".tunnel-secret") + if not secret: + return "" + return _hs256(agent_token, "a2app-ui:share:v1:" + secret) + + +def session_cookie_header(name: str, value: str, tunnel: bool) -> str: + # Lax, not Strict: a share link opened from chat is a cross-site + # navigation, and Strict would withhold the cookie on the redirect that + # follows the exchange. Writes do not lean on SameSite — the origin check + # and the per-ingress value do that work. + return ( + f"{name}={value}; Path=/; HttpOnly; SameSite=Lax" + + ("; Secure" if tunnel else "") + ) + + +def guard_request( + project_dir: Path, + method: str, + headers: Mapping[str, str], + cookies: Mapping[str, str], +) -> Optional[Tuple[int, Dict[str, Any]]]: + """THE caller guard: None to proceed, else (status, error envelope). + + Two independent checks, in order: + 1. Origin — a foreign Origin on a mutation is refused (403). Reads pass: + for those, withholding the CORS grant is the browser-side defence. + 2. Caller — a mutation, or ANY request through the tunnel, must carry + a credential: the agent token (constant-time compare) or this + ingress's UI session cookie. The Origin plays no part here. + A project with no agent token provisioned is never locked out (native + parity: the token is minted at launch, so this is a pre-launch edge). + """ + method = method.upper() + mutating = method in MUTATING + origin = next((v for k, v in headers.items() if k.lower() == "origin"), "") + if origin and mutating and not origin_allowed(project_dir, origin): + return 403, { + "a2app": True, + "ok": False, + "code": "forbidden_origin", + "message": "Cross-origin writes are not allowed.", + } + + tunnel = is_tunnel_request(headers) + # Preflights never carry credentials (browsers strip them by spec). + if method == "OPTIONS" or not (mutating or tunnel): + return None + expected = _read_secret(project_dir, ".agent-token") + if not expected: + return None + + lowered = {k.lower(): v for k, v in headers.items()} + presented = next( + (lowered[h.lower()] for h in TOKEN_HEADERS if lowered.get(h.lower())), "" + ).strip() + if presented and hmac.compare_digest(presented.encode(), expected.encode()): + return None + session = session_value(project_dir, expected, tunnel) + cookie = cookies.get(session_cookie_name(expected), "") + if session and cookie and hmac.compare_digest(cookie.encode(), session.encode()): + return None + + if tunnel: + return 401, { + "a2app": True, + "ok": False, + "code": "share_session_required", + "message": ( + "This app is shared by link. Open the full share link you " + "were given (it carries ?a2app_share=...)." + ), + } + return 401, { + "a2app": True, + "ok": False, + "code": "unauthorized", + "message": "agent token required", + "hint": ( + "Send X-A2App-Token: on writes." + ), + } + def _server_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") @@ -252,35 +423,17 @@ def _ops_raw(self) -> bytes: except Exception: return b"{}" - def _agent_token(self) -> str: - try: - return ( - (self.project_dir / ".agent-token").read_text(encoding="utf-8").strip() - ) - except Exception: - return "" - def _origin_allowed(self, origin: str) -> bool: - """Loopback, or the one public origin the host is currently sharing. - - AgentAppManager.start_tunnel writes `.tunnel-origin` and stop_tunnel - deletes it, so the grant lasts exactly as long as the tunnel. Read per - request for the same reason the native guard does: sharing starts and - stops without restarting anything. Loopback-only was not a safe - default for a shared app, it was a broken one — browsers send `Origin` - on same-origin writes too, so through a tunnel every write was refused. - """ - if LOOPBACK_ORIGIN.match(origin): - return True - try: - shared = ( - (self.project_dir / ".tunnel-origin") - .read_text(encoding="utf-8") - .strip() - ) - except Exception: - return False # no file = not sharing = loopback only - return bool(shared) and origin.lower() == shared.lower() + return origin_allowed(self.project_dir, origin) + + def _deny(self, request): + """guard_request as a response: None to proceed, else the refusal.""" + denied = guard_request( + self.project_dir, request.method, request.headers, request.cookies + ) + if denied is None: + return None + return self._json(request, denied[0], denied[1]) def _json(self, request, status: int, payload: Dict[str, Any]): from aiohttp import web @@ -311,16 +464,85 @@ def _log_action(self, entry: Dict[str, Any]) -> None: async def _handle(self, request): path = request.path + if ( + request.method == "GET" + and SHARE_PARAM in request.query + and is_tunnel_request(request.headers) + ): + return self._share_exchange(request) + own = ( + request.method == "GET" + and path in ("/api/_a2app", "/api/_a2app/describe", "/api/_ops") + ) or path == "/api/ops" or path.startswith("/api/ops/") + if own: + denied = self._deny(request) + if denied is not None: + return denied if request.method == "GET" and path == "/api/_a2app": return self._identity(request) if request.method == "GET" and path == "/api/_a2app/describe": return self._describe(request) if request.method == "GET" and path == "/api/_ops": return self._ops_manifest(request) - if path == "/api/ops" or path.startswith("/api/ops/"): + if own: return await self._invoke(request) + # TODO(passthrough-auth): the app's own surface is not guarded yet; + # the follow-up applies guard_request here too. return await self._passthrough(request) + def _share_exchange(self, request): + """Trade the share link's secret for the tunnel UI session, then + redirect to the same URL without it (out of the address bar, history + and anything the visitor copies onward).""" + from aiohttp import web + + secret = _read_secret(self.project_dir, ".tunnel-secret") + presented = request.query.get(SHARE_PARAM, "") + if not ( + secret + and presented + and hmac.compare_digest(presented.encode(), secret.encode()) + ): + return self._json( + request, + 403, + { + "a2app": True, + "ok": False, + "code": "share_link_invalid", + "message": ( + "This share link is invalid or has expired. Ask the " + "owner for a fresh one." + ), + }, + ) + rest = [(k, v) for k, v in request.query.items() if k != SHARE_PARAM] + resp = web.HTTPFound(str(request.rel_url.with_query(rest))) + token = _read_secret(self.project_dir, ".agent-token") + value = session_value(self.project_dir, token, tunnel=True) + if value: + resp.headers["Set-Cookie"] = session_cookie_header( + session_cookie_name(token), value, tunnel=True + ) + resp.headers["Cache-Control"] = "no-store" + return resp + + def _local_session_cookie(self, request) -> Optional[str]: + """Set-Cookie for the app's own UI on local ingress, when it lacks a + valid session. Anyone who can reach loopback gets one — which is + everyone who could already read .agent-token, so it grants nothing + new; what it replaces is trusting a forgeable Origin header.""" + if is_tunnel_request(request.headers): + return None + token = _read_secret(self.project_dir, ".agent-token") + value = session_value(self.project_dir, token, tunnel=False) + if not value: + return None + name = session_cookie_name(token) + if request.cookies.get(name) == value: + return None + return session_cookie_header(name, value, tunnel=False) + # ── A2App endpoints ──────────────────────────────────────────────────── def _identity(self, request): @@ -378,7 +600,7 @@ def _ops_manifest(self, request): # ── operation invocation ─────────────────────────────────────────────── async def _invoke(self, request): - origin = request.headers.get("Origin", "") + # Caller already cleared guard_request in _handle. # TODO(lui-compat): older clients/CLIs send the X-LUI-* header. Accept # either signature; drop the X-LUI-* fallback once every deployed app # and client speaks X-A2App-*. @@ -388,48 +610,6 @@ async def _invoke(self, request): or "unknown" )[:120] - # Check 1 (browser): mutations from foreign origins are refused - # outright; loopback origins are the app's own UI and pass free, as - # does the shared origin while the user is tunnelling this app. - if origin and not self._origin_allowed(origin): - if request.method in MUTATING: - return self._json( - request, - 403, - { - "a2app": True, - "ok": False, - "code": "forbidden_origin", - "message": "Cross-origin writes are not allowed.", - }, - ) - # Check 2 (programs): no Origin means a programmatic caller — a - # mutation must present the project's agent token. A project with no - # token provisioned is never locked out (native parity). - elif not origin and request.method in MUTATING: - expected = self._agent_token() - # TODO(lui-compat): accept the legacy token header too. - presented = ( - request.headers.get("X-A2App-Token") - or request.headers.get("X-LUI-Token") - or "" - ).strip() - if expected and presented != expected: - return self._json( - request, - 401, - { - "a2app": True, - "ok": False, - "code": "unauthorized", - "message": "agent token required", - "hint": ( - "Send X-A2App-Token: on writes." - ), - }, - ) - manifest, problems = load_external_manifest(self.project_dir) if problems: return self._json( @@ -644,6 +824,14 @@ async def _passthrough(self, request): for k, v in up.headers.items(): if k.lower() not in HOP_HEADERS: resp.headers[k] = v + # The app's own UI gets its session with the page that boots + # it; its same-origin fetches then carry it automatically. + if request.method == "GET" and (up.content_type or "").startswith( + "text/html" + ): + cookie = self._local_session_cookie(request) + if cookie: + resp.headers.add("Set-Cookie", cookie) await resp.prepare(request) async for chunk in up.content.iter_chunked(64 * 1024): await resp.write(chunk) diff --git a/app/agent_app/manager.py b/app/agent_app/manager.py index 1b24c92a..a81b8941 100644 --- a/app/agent_app/manager.py +++ b/app/agent_app/manager.py @@ -954,12 +954,20 @@ def _load_projects(self) -> None: saved_tunnel = project_data.get("tunnelUrl") if saved_tunnel: try: + import urllib.error import urllib.request req = urllib.request.Request( saved_tunnel, method="HEAD" ) - urllib.request.urlopen(req, timeout=3) + try: + urllib.request.urlopen(req, timeout=3) + except urllib.error.HTTPError as he: + # 401 is the app's own answer to a + # visitor without a share session: the + # tunnel is up. Anything else is dead. + if he.code != 401: + raise project.tunnel_url = saved_tunnel logger.info( f"[AGENT_APP] Tunnel still active for '{project.name}': {saved_tunnel}" @@ -3158,6 +3166,7 @@ async def _convert_tree( "token.json", ".superuser", ".agent-token", + ".tunnel-secret", ".jwt_secret", ".npmrc", ".netrc", @@ -3330,6 +3339,7 @@ async def _import_project_tree( # Never trust shipped credentials or runtime state. (dest / ".superuser").unlink(missing_ok=True) (dest / ".tunnel-origin").unlink(missing_ok=True) + (dest / ".tunnel-secret").unlink(missing_ok=True) # Rewrite identity + port (pipeline start command embeds the port). old_port = manifest.get("port") @@ -4574,6 +4584,7 @@ def export_project_zip(self, project_id: str) -> Path: # Host-local, tunnel-lifetime state: an exported app must not # arrive somewhere else already trusting a foreign origin. ".tunnel-origin", + ".tunnel-secret", } with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: @@ -4816,7 +4827,8 @@ async def start_tunnel( self._publish_tunnel_origin(project, url) self._save_projects() logger.info(f"[AGENT_APP] Tunnel started for {project.name}: {url}") - return url + # Hand back the SHARE link: the bare tunnel URL admits nobody. + return self.get_tunnel_share_url(project_id) else: self._terminate_process(proc) self._close_tunnel_log(log_handle) @@ -4917,6 +4929,51 @@ def _publish_tunnel_origin( logger.info(f"[AGENT_APP] Shared origin revoked for {project.name}") except Exception as e: logger.warning(f"[AGENT_APP] Could not update {path.name}: {e}") + self._publish_tunnel_secret(project, bool(url)) + + @staticmethod + def _tunnel_secret_file(project: AgentAppProject) -> Path: + return Path(project.path) / ".tunnel-secret" + + def _publish_tunnel_secret(self, project: AgentAppProject, on: bool) -> None: + """Mint (on) or revoke (off) the share link's secret. + + Through a tunnel the origin grant above authenticates nobody — every + request needs a credential, and a visitor's browser only gets one by + trading this secret (?a2app_share=, see a2app_proxy.guard_request and + _a2app_lib.js authorizeCaller). Fresh per tunnel start; deleting it + ends every shared session at once. An existing secret is kept when + the same tunnel is re-published, so links already sent keep working. + """ + path = self._tunnel_secret_file(project) + try: + if on: + if not path.exists() or not path.read_text(encoding="utf-8").strip(): + path.write_text(secrets.token_urlsafe(32), encoding="utf-8") + try: + os.chmod(path, 0o600) + except Exception: + pass + elif path.exists(): + path.unlink() + except Exception as e: + logger.warning(f"[AGENT_APP] Could not update {path.name}: {e}") + + def get_tunnel_share_url(self, project_id: str) -> Optional[str]: + """The link to hand out: the tunnel URL plus the share secret. The + bare tunnel URL alone admits nobody.""" + project = self.projects.get(project_id) + if not project or not project.tunnel_url: + return None + try: + secret = ( + self._tunnel_secret_file(project).read_text(encoding="utf-8").strip() + ) + except Exception: + secret = "" + if not secret: + return project.tunnel_url + return f"{project.tunnel_url.rstrip('/')}/?a2app_share={secret}" async def _parse_cloudflare_url( self, diff --git a/app/agent_app/test_a2app_external.py b/app/agent_app/test_a2app_external.py index e2c00b51..0743a79e 100644 --- a/app/agent_app/test_a2app_external.py +++ b/app/agent_app/test_a2app_external.py @@ -230,6 +230,152 @@ def __init__(self, path: Path): self.project_type = "external" +SHARED = "https://shared-demo.trycloudflare.com" +# What cloudflared delivers: Cloudflare's stamps plus the public Host. +VIA_TUNNEL = { + "Host": "shared-demo.trycloudflare.com", + "Cf-Ray": "8c0ffee-LHR", + "Cf-Connecting-Ip": "203.0.113.9", + "X-Forwarded-For": "203.0.113.9", +} + + +def _set_cookie(resp) -> "tuple[str, str]": + """(name, value) from the response's A2App session Set-Cookie.""" + for raw in resp.headers.getall("Set-Cookie", []): + pair = raw.split(";", 1)[0] + if pair.startswith("a2app_s_"): + name, _, value = pair.partition("=") + return name, value + raise AssertionError("no a2app session cookie issued") + + +async def _auth_matrix(http, base: str, tmp: Path, seen) -> None: + """The auth bypass (allowed Origin skipped the token check) and the + browser-session design that replaced it. Origin and caller are + independent: an allowed Origin never authenticates anything.""" + create = f"{base}/api/ops/todos/create" + loopback = {"Origin": f"http://127.0.0.1:{PROXY_PORT}"} + before = len(seen["todos"]) + + async def post(headers, cookie=None, title="x"): + h = dict(headers) + if cookie: + h["Cookie"] = f"{cookie[0]}={cookie[1]}" + async with http.post(create, json={"title": title}, headers=h) as r: + return r.status, await r.json() + + # ── refused ── + status, body = await post({"Origin": "https://evil.example"}) + assert status == 403 and body["code"] == "forbidden_origin", body + status, _ = await post({"Origin": "https://evil.example", **{"X-A2App-Token": TOKEN}}) + assert status == 403, "a token does not launder a foreign origin" + for label, headers in ( + ("no Origin, no token", {}), + ("no Origin, wrong token", {"X-A2App-Token": "nope"}), + ("loopback Origin, no token", loopback), + ("other loopback port, no token", {"Origin": "http://localhost:1"}), + ("loopback Origin, wrong token", {**loopback, "X-A2App-Token": "nope"}), + ("token prefix", {"X-A2App-Token": TOKEN[:-1]}), + ): + status, body = await post(headers) + assert status == 401 and body["code"] == "unauthorized", (label, status) + (tmp / ".tunnel-origin").write_text(SHARED, encoding="utf-8") + status, _ = await post({"Origin": SHARED}) + assert status == 401, "shared Origin alone must not authenticate" + status, _ = await post({"Origin": SHARED, "X-A2App-Token": "nope"}) + assert status == 401 + assert len(seen["todos"]) == before, "a refused write reached the app" + + # reads stay open locally + async with http.get(f"{base}/api/ops/todos/list") as r: + assert r.status == 200 + + # ── the agent: token, with or without an Origin ── + status, _ = await post({"X-A2App-Token": TOKEN}, title="agent") + assert status == 200 + status, _ = await post({**loopback, "X-A2App-Token": TOKEN}, title="agent+origin") + assert status == 200 + status, _ = await post({"X-LUI-Token": TOKEN}, title="legacy") + assert status == 200, "legacy header still accepted" + + # ── the app's own UI, locally: the page that boots it issues the session + async with http.get(f"{base}/") as r: + assert r.status == 200 and (await r.text()) == "UPSTREAM OK" + local = _set_cookie(r) + raw = r.headers.getall("Set-Cookie")[0] + assert "HttpOnly" in raw and "SameSite=Lax" in raw and "Secure" not in raw + async with http.get(f"{base}/", headers={"Cookie": f"{local[0]}={local[1]}"}) as r: + assert "Set-Cookie" not in r.headers, "a valid session is not re-issued" + async with http.get(f"{base}/api/todos") as r: # JSON: no session minted + assert "Set-Cookie" not in r.headers + status, _ = await post(loopback, cookie=local, title="ui") + assert status == 200 + status, _ = await post(loopback, cookie=(local[0], local[1][:-1] + "0")) + assert status == 401, "a tampered session is no session" + + # ── through the tunnel ── + async with http.get(f"{base}/api/_a2app", headers=VIA_TUNNEL) as r: + body = await r.json() + assert r.status == 401 and body["code"] == "share_session_required" + # Either signal alone marks the tunnel: a public Host, or a Cloudflare + # stamp on a loopback Host. + for only in ({"Host": VIA_TUNNEL["Host"]}, {"Cf-Ray": VIA_TUNNEL["Cf-Ray"]}): + async with http.get(f"{base}/api/_a2app", headers=only) as r: + assert r.status == 401, only + status, _ = await post({**VIA_TUNNEL, **loopback}) + assert status == 401, "a forged loopback Origin through the tunnel" + status, _ = await post({**VIA_TUNNEL, "Origin": SHARED}, cookie=local) + assert status == 401, "a local session is never a tunnel credential" + async with http.get(f"{base}/", headers=VIA_TUNNEL) as r: + assert "Set-Cookie" not in r.headers, "tunnel sessions only via the share link" + + secret = "share-secret-for-tests-0123456789abcdef" + (tmp / ".tunnel-secret").write_text(secret, encoding="utf-8") + async with http.get( + f"{base}/?a2app_share=wrong", headers=VIA_TUNNEL, allow_redirects=False + ) as r: + assert r.status == 403 and (await r.json())["code"] == "share_link_invalid" + async with http.get( + f"{base}/?a2app_share={secret}&tab=2", + headers=VIA_TUNNEL, + allow_redirects=False, + ) as r: + assert r.status == 302, r.status + assert r.headers["Location"] == "/?tab=2", "secret must leave the URL" + shared = _set_cookie(r) + assert "Secure" in r.headers["Set-Cookie"] + async with http.get( + f"{base}/?a2app_share={secret}", allow_redirects=False + ) as r: + assert r.status == 200, "locally the parameter means nothing" + + status, _ = await post({**VIA_TUNNEL, "Origin": SHARED}, cookie=shared, title="visitor") + assert status == 200 + async with http.get( + f"{base}/api/_a2app", + headers={**VIA_TUNNEL, "Cookie": f"{shared[0]}={shared[1]}"}, + ) as r: + assert r.status == 200 + status, _ = await post(loopback, cookie=shared) + assert status == 401, "a tunnel session is not a local credential" + status, _ = await post({**VIA_TUNNEL, "X-A2App-Token": TOKEN}, title="remote agent") + assert status == 200 + async with http.post( + create, json={"title": "evil"}, headers={**VIA_TUNNEL, "Origin": "https://evil.example", + "Cookie": f"{shared[0]}={shared[1]}"} + ) as r: + assert r.status == 403, "a session does not launder a foreign origin" + + # stopping the tunnel ends every shared session at once + (tmp / ".tunnel-secret").unlink() + (tmp / ".tunnel-origin").unlink() + status, _ = await post({**VIA_TUNNEL, "Origin": SHARED}, cookie=shared) + assert status in (401, 403) + titles = [t["title"] for t in seen["todos"][before:]] + assert titles == ["agent", "agent+origin", "legacy", "ui", "visitor", "remote agent"], titles + + async def _proxy_suite(tmp: Path) -> None: import aiohttp @@ -244,7 +390,8 @@ async def _proxy_suite(tmp: Path) -> None: base = f"http://127.0.0.1:{PROXY_PORT}" auth = {"X-A2App-Token": TOKEN, "X-A2App-Agent": "test-suite"} - async with aiohttp.ClientSession() as http: + # No cookie jar: every cookie in this suite is sent deliberately. + async with aiohttp.ClientSession(cookie_jar=aiohttp.DummyCookieJar()) as http: # identity: the structural probe async with http.get(f"{base}/api/_a2app") as r: ident = await r.json() @@ -301,28 +448,7 @@ async def _proxy_suite(tmp: Path) -> None: async with http.get(f"{base}/api/ops/todos/get", params={"id": "1"}) as r: assert r.status == 200 and (await r.json())["title"] == "call John" - # auth: mutation without token -> 401; GET needs none - async with http.post(f"{base}/api/ops/todos/create", json={"title": "x"}) as r: - assert r.status == 401 and (await r.json())["code"] == "unauthorized" - async with http.get(f"{base}/api/ops/todos/list") as r: - assert r.status == 200 - - # origin guard: foreign-origin mutation refused outright; loopback ok - async with http.post( - f"{base}/api/ops/todos/create", - json={"title": "evil"}, - headers={"Origin": "https://evil.example"}, - ) as r: - assert r.status == 403 and (await r.json())["code"] == "forbidden_origin" - async with http.post( - f"{base}/api/ops/todos/create", - json={"title": "ui"}, - headers={"Origin": f"http://127.0.0.1:{PROXY_PORT}"}, - ) as r: - assert r.status == 200 - assert r.headers["Access-Control-Allow-Origin"] == ( - f"http://127.0.0.1:{PROXY_PORT}" - ) + await _auth_matrix(http, base, tmp, seen) # unknown op -> 404 envelope, never a silent passthrough async with http.post(f"{base}/api/ops/nope", json={}, headers=auth) as r: @@ -339,7 +465,7 @@ async def _proxy_suite(tmp: Path) -> None: async with http.get(f"{base}/") as r: assert r.status == 200 and (await r.text()) == "UPSTREAM OK" async with http.get(f"{base}/api/todos") as r: - assert r.status == 200 and len(await r.json()) == 2 + assert r.status == 200 and len(await r.json()) == len(seen["todos"]) # ops_verify drives the real surface: boom must fail the verdict, # wipe must be skipped (destructive), the rest pass diff --git a/app/agent_app/test_a2app_native_auth.py b/app/agent_app/test_a2app_native_auth.py new file mode 100644 index 00000000..aa408019 --- /dev/null +++ b/app/agent_app/test_a2app_native_auth.py @@ -0,0 +1,280 @@ +"""Native (PocketBase) adapter caller auth, against the real blueprint hooks. + +The bypass this pins: `_system.pb.js`'s token guard ran only when there was +NO Origin header, so any request carrying an allowed one — loopback, or the +shared tunnel origin — wrote without a credential. Tunnel traffic reaches the +app over loopback and can send any Origin it likes, so a shared app was +writable by anyone with the URL. Origin and caller are now independent checks +(_a2app_lib.js authorizeCaller, rule-for-rule with a2app_proxy.guard_request, +whose matrix lives in test_a2app_external.py). + +Boots the pinned PocketBase on the blueprint's own pb_hooks — the system hooks +are the unit under test, so nothing is stubbed. SKIPPED if the binary is not +in the tooling cache. + +Run: python -m app.agent_app.test_a2app_native_auth + +Style follows test_data_safety.py: a module-level assert script, no pytest. +""" + +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +try: + sys.stdout.reconfigure(encoding="utf-8") +except Exception: + pass + +REPO = Path(__file__).resolve().parents[2] +BLUEPRINT = REPO / "agent-app" / "blueprint" +TOKEN = "native-test-agent-token" +SHARED = "https://shared-demo.trycloudflare.com" +SECRET = "native-share-secret-0123456789abcdef" +VIA_TUNNEL = { + "Host": "shared-demo.trycloudflare.com", + "Cf-Ray": "8c0ffee-LHR", + "Cf-Connecting-Ip": "203.0.113.9", + "X-Forwarded-For": "203.0.113.9", +} +# The blueprint migration's rules are scaffold placeholders; authMode "none" +# renders them open, which is exactly the posture the token guard protects. +MIGRATION = """/// +migrate((app) => { + const c = new Collection({ + type: 'base', name: 'items', + listRule: '', viewRule: '', createRule: '', updateRule: '', deleteRule: '', + fields: [{ name: 'title', type: 'text', required: true, max: 200 }, + { name: 'done', type: 'bool' }], + }); + app.save(c); +}, (app) => { app.delete(app.findCollectionByNameOrId('items')); }); +""" + + +def _pinned_pb_binary() -> Path: + version = (REPO / "agent-app" / "spec" / "pocketbase.version").read_text( + encoding="utf-8" + ).strip() + cache = os.environ.get("AGENT_APP_PB_CACHE") + if cache: + root = Path(cache) + elif os.name == "nt": + root = Path(os.environ["LOCALAPPDATA"]) / "craftos-agent-app" / "pb" + else: + root = Path.home() / ".cache" / "craftos-agent-app" / "pb" + return root / version / ("pocketbase.exe" if os.name == "nt" else "pocketbase") + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +_opener = urllib.request.build_opener(_NoRedirect) + + +def _req(base, method, path, headers=None, body=None, cookie=None): + """(status, headers, json-or-text). Never follows redirects.""" + h = dict(headers or {}) + data = None + if body is not None: + data = json.dumps(body).encode() + h.setdefault("Content-Type", "application/json") + if cookie: + h["Cookie"] = f"{cookie[0]}={cookie[1]}" + req = urllib.request.Request(base + path, data=data, method=method, headers=h) + try: + resp = _opener.open(req, timeout=10) + except urllib.error.HTTPError as e: + resp = e + raw = resp.read().decode("utf-8", errors="replace") + try: + payload = json.loads(raw) if raw else None + except ValueError: + payload = raw + return resp.status if hasattr(resp, "status") else resp.code, resp.headers, payload + + +def _session_cookie(headers): + for raw in headers.get_all("Set-Cookie") or []: + pair = raw.split(";", 1)[0] + if pair.startswith("a2app_s_"): + name, _, value = pair.partition("=") + return name, value, raw + return None + + +def _suite(base: str, proj: Path, superuser) -> None: + create = "/api/collections/items/records" + loopback = {"Origin": base} + + def post(headers, cookie=None, title="x", path=create): + body = {"title": title} if path == create else {} + return _req(base, "POST", path, headers, body, cookie)[0] + + def count(): + _, _, page = _req(base, "GET", create + "?perPage=1") + return page["totalItems"] + + before = count() + + # ── refused: Origin is never a credential ── + assert post({"Origin": "https://evil.example"}) == 403 + for label, headers in ( + ("no Origin, no token", {}), + ("no Origin, wrong token", {"X-A2App-Token": "nope"}), + ("loopback Origin, no token", loopback), + ("other loopback port, no token", {"Origin": "http://localhost:1"}), + ("loopback Origin, wrong token", {**loopback, "X-A2App-Token": "nope"}), + ): + assert post(headers) == 401, label + assert post(loopback, path="/api/ops/items/clear-done") == 401, "ops route too" + assert count() == before, "a refused write landed" + + # ── the agent ── + assert post({"X-A2App-Token": TOKEN}) == 200 + assert post({**loopback, "X-A2App-Token": TOKEN}) == 200 + assert post({"X-A2App-Token": TOKEN}, path="/api/ops/items/clear-done") == 200 + + # ── signed-in principal (multi-user apps' frontends) ── + _, _, auth = _req( + base, + "POST", + "/api/collections/_superusers/auth-with-password", + {}, + {"identity": superuser[0], "password": superuser[1]}, + ) + assert post({**loopback, "Authorization": auth["token"]}) == 200 + + # ── the app's own UI, locally: the SPA entry issues the session ── + status, headers, _ = _req(base, "GET", "/") + assert status == 200 + local = _session_cookie(headers) + assert local, "the page that boots the UI must hand it a session" + assert "HttpOnly" in local[2] and "SameSite=Lax" in local[2] + assert "Secure" not in local[2] + local = local[:2] + _, headers, _ = _req(base, "GET", "/", cookie=local) + assert _session_cookie(headers) is None, "valid session not re-issued" + _, headers, _ = _req(base, "GET", "/api/health") + assert _session_cookie(headers) is None, "API responses mint nothing" + assert post(loopback, cookie=local) == 200 + assert post(loopback, cookie=(local[0], local[1][:-1] + "0")) == 401 + + # ── through the tunnel ── + (proj / ".tunnel-origin").write_text(SHARED, encoding="utf-8") + assert post({"Origin": SHARED}) == 401, "shared Origin alone, no token" + assert post({"Origin": SHARED, "X-A2App-Token": "nope"}) == 401 + status, _, body = _req(base, "GET", create, VIA_TUNNEL) + assert status == 401 and body["code"] == "share_session_required", body + assert _req(base, "GET", "/", VIA_TUNNEL)[0] == 401, "the UI itself is gated" + # Either signal alone marks the tunnel: a public Host (request.host in Go, + # not the header map) or a Cloudflare stamp on a loopback Host. + assert _req(base, "GET", create, {"Host": "shared-demo.trycloudflare.com"})[0] == 401 + assert _req(base, "GET", create, {"Cf-Ray": "8c0ffee-LHR"})[0] == 401 + assert post({**VIA_TUNNEL, **loopback}) == 401, "forged loopback Origin" + assert post({**VIA_TUNNEL, "Origin": SHARED}, cookie=local) == 401 + + (proj / ".tunnel-secret").write_text(SECRET, encoding="utf-8") + status, _, body = _req(base, "GET", "/?a2app_share=wrong", VIA_TUNNEL) + assert status == 403 and body["code"] == "share_link_invalid" + status, headers, _ = _req(base, "GET", f"/?a2app_share={SECRET}&tab=2", VIA_TUNNEL) + assert status == 302, status + assert headers["Location"] == "/?tab=2", headers["Location"] + shared = _session_cookie(headers) + assert shared and "Secure" in shared[2] + shared = shared[:2] + + assert _req(base, "GET", "/", VIA_TUNNEL, cookie=shared)[0] == 200 + assert _req(base, "GET", create, VIA_TUNNEL, cookie=shared)[0] == 200 + assert post({**VIA_TUNNEL, "Origin": SHARED}, cookie=shared) == 200 + assert post(loopback, cookie=shared) == 401, "tunnel session is not local" + assert post({**VIA_TUNNEL, "X-A2App-Token": TOKEN}) == 200 + assert post({**VIA_TUNNEL, "Origin": "https://evil.example"}, cookie=shared) == 403 + + (proj / ".tunnel-secret").unlink() + (proj / ".tunnel-origin").unlink() + assert post({**VIA_TUNNEL, "Origin": SHARED}, cookie=shared) in (401, 403) + assert _req(base, "GET", create, VIA_TUNNEL, cookie=shared)[0] == 401 + + +def main() -> None: + pb = _pinned_pb_binary() + if not pb.exists(): + print(f"native caller auth: SKIPPED (no PocketBase at {pb})") + return + with tempfile.TemporaryDirectory() as tmp: + proj = Path(tmp) / "app" + shutil.copytree(BLUEPRINT / "pb" / "pb_hooks", proj / "pb" / "pb_hooks") + (proj / "pb" / "pb_migrations").mkdir(parents=True) + (proj / "pb" / "pb_migrations" / "1700000000_items.js").write_text( + MIGRATION, encoding="utf-8" + ) + (proj / "pb" / "pb_public").mkdir(parents=True) + (proj / "pb" / "pb_public" / "index.html").write_text( + "app", encoding="utf-8" + ) + shutil.copy(BLUEPRINT / "operations.json", proj / "operations.json") + (proj / "manifest.json").write_text( + json.dumps({"id": "nativeauth", "authMode": "none"}), encoding="utf-8" + ) + (proj / ".agent-token").write_text(TOKEN, encoding="utf-8") + pb_data = proj / "pb" / "pb_data" + superuser = ("agent@agent-app.local", "native-auth-password-123") + subprocess.run( + [str(pb), "superuser", "upsert", *superuser, "--dir", str(pb_data)], + capture_output=True, + timeout=120, + check=True, + ) + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + proc = subprocess.Popen( + [ + str(pb), + "serve", + f"--http=127.0.0.1:{port}", + "--dir", + str(pb_data), + "--hooksDir", + str(proj / "pb" / "pb_hooks"), + "--migrationsDir", + str(proj / "pb" / "pb_migrations"), + "--publicDir", + str(proj / "pb" / "pb_public"), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + base = f"http://127.0.0.1:{port}" + try: + for _ in range(300): + try: + urllib.request.urlopen(base + "/api/health", timeout=1) + break + except Exception: + time.sleep(0.2) + else: + raise AssertionError("PocketBase never became healthy") + _suite(base, proj, superuser) + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except Exception: + proc.kill() + print("native caller auth (blueprint hooks on real PocketBase): OK") + + +if __name__ == "__main__": + main() diff --git a/app/agent_app/test_tunnel.py b/app/agent_app/test_tunnel.py index 37fb124e..dcc5d46c 100644 --- a/app/agent_app/test_tunnel.py +++ b/app/agent_app/test_tunnel.py @@ -133,12 +133,29 @@ def _check_origin_grant(tmp: Path) -> None: mgr._publish_tunnel_origin(project, URL + "/") assert origin_file.read_text(encoding="utf-8").strip() == URL, "bad origin file" + # The origin grant authenticates nobody; the share secret does. It is + # minted with the grant, survives a re-publish of the same tunnel (links + # already sent keep working), and dies with it. + secret_file = tmp / ".tunnel-secret" + secret = secret_file.read_text(encoding="utf-8").strip() + assert len(secret) >= 32, "share secret must be minted with the grant" + mgr._publish_tunnel_origin(project, URL) + assert secret_file.read_text(encoding="utf-8").strip() == secret + mgr.projects = {project.id: project} + project.tunnel_url = URL + assert mgr.get_tunnel_share_url(project.id) == f"{URL}/?a2app_share={secret}" + mgr._publish_tunnel_origin(project, None) assert not origin_file.exists(), "stopping the tunnel must revoke the grant" + assert not secret_file.exists(), "stopping the tunnel must end every session" mgr._publish_tunnel_origin(project, None) # idempotent: stop_tunnel runs often + # A new tunnel is a new secret: old links must not reopen it. + mgr._publish_tunnel_origin(project, URL) + assert secret_file.read_text(encoding="utf-8").strip() != secret + -print_origin = "§3 shared origin published and revoked: OK" +print_origin = "§3 shared origin + share secret published and revoked: OK" def _check_serving_port(tmp: Path) -> None: diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index cad8e48f..e7feaa91 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -3939,14 +3939,17 @@ async def _handle_agent_app_state_update(self, data: Dict[str, Any]) -> None: async def _handle_agent_app_sharing_info(self, project_id: str) -> None: """Return sharing info (LAN URL, tunnel URL).""" lan_url = self._agent_app_manager.get_lan_url(project_id) - project = self._agent_app_manager.get_project(project_id) await self._broadcast( { "type": "agent_app_sharing_info", "data": { "projectId": project_id, "lanUrl": lan_url, - "tunnelUrl": project.tunnel_url if project else None, + # The share link (tunnel URL + secret): the bare tunnel + # URL admits nobody. + "tunnelUrl": self._agent_app_manager.get_tunnel_share_url( + project_id + ), }, } ) From eef75eb4d2b712c1cdbf922f5b918e5fed8d286a Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Mon, 21 Sep 2026 15:23:01 +0100 Subject: [PATCH 2/5] fix validate checks --- agent-app/tools/src/commands/validate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-app/tools/src/commands/validate.ts b/agent-app/tools/src/commands/validate.ts index 0b671063..bcab751d 100644 --- a/agent-app/tools/src/commands/validate.ts +++ b/agent-app/tools/src/commands/validate.ts @@ -15,7 +15,7 @@ import { execFileSync, spawn } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { fileMatchesCanon, recordFileHash, verifySystemHashes } from '../lib/hashes.ts'; +import { fileMatchesCanon, recordFileHash } from '../lib/hashes.ts'; import { log } from '../lib/log.ts'; import { ensurePbBinary } from './pb.ts'; From 4ac2907f4c2fb244a344303bfe06cca65586188b Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Tue, 22 Sep 2026 09:01:22 +0100 Subject: [PATCH 3/5] fix(a2app): fail closed without agent token, embed apps same-site, warn on destructive GET ops --- agent-app/blueprint/pb/pb_hooks/_a2app_lib.js | 16 ++++++- agent-app/tools/src/commands/validate.ts | 8 ++++ app/agent_app/a2app_proxy.py | 29 ++++++++++-- app/agent_app/manager.py | 20 +++++++++ app/agent_app/ops_manifest.py | 21 +++++++++ app/agent_app/ops_verify.py | 4 ++ app/agent_app/test_a2app_external.py | 44 +++++++++++++++++++ app/agent_app/test_a2app_native_auth.py | 13 ++++++ app/agent_app/test_tunnel.py | 36 +++++++++++++++ .../src/pages/AgentApp/AgentAppPage.tsx | 7 +-- .../frontend/src/pages/AgentApp/iframePool.ts | 21 +++++++++ 11 files changed, 211 insertions(+), 8 deletions(-) diff --git a/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js b/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js index 11166536..44aab3d2 100644 --- a/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js +++ b/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js @@ -629,7 +629,21 @@ function authorizeCaller(e) { } var token = readProjectSecret('.agent-token'); - if (token === '') return null; // no token provisioned — do not lock the app out + if (token === '') { + // Locally, a missing token must not lock the app out (it is minted at + // launch). Through the tunnel it FAILS CLOSED: no token means no + // credential can be checked, and "allow" would make a shared app + // publicly writable. Mirrors a2app_proxy.guard_request. + if (!tunnel) return null; + return { + status: 503, + body: { + ok: false, + code: 'share_unavailable', + error: 'This app has no access token, so it cannot be shared. Restart it from CraftBot.', + }, + }; + } // TODO(lui-compat): also accept the legacy X-LUI-Token from older clients. var presented = (headerOf(e, 'X-A2App-Token') || headerOf(e, 'X-LUI-Token')).trim(); diff --git a/agent-app/tools/src/commands/validate.ts b/agent-app/tools/src/commands/validate.ts index bcab751d..90f0f8bd 100644 --- a/agent-app/tools/src/commands/validate.ts +++ b/agent-app/tools/src/commands/validate.ts @@ -702,6 +702,14 @@ function validateOps(projectDir: string): void { throw new Error(`${op.name}: http/job executor needs method + /api/... path`); } + // A destructive GET op: local reads carry no credential (authorizeCaller), + // so any page can trigger it with a link or top-level navigation. + if (op.destructive === true && method.toUpperCase() === 'GET') { + log.warn( + `${op.name}: destructive op declared as GET — any page can trigger it with a link (reads carry no credential); declare it POST`, + ); + } + // O3 structural check: non-system ops must resolve to a declared hook route. // System entries may point at PB built-ins (e.g. /api/health). const key = `${method} ${path}`; diff --git a/app/agent_app/a2app_proxy.py b/app/agent_app/a2app_proxy.py index 5d00b789..0e53277e 100644 --- a/app/agent_app/a2app_proxy.py +++ b/app/agent_app/a2app_proxy.py @@ -177,8 +177,9 @@ def guard_request( 2. Caller — a mutation, or ANY request through the tunnel, must carry a credential: the agent token (constant-time compare) or this ingress's UI session cookie. The Origin plays no part here. - A project with no agent token provisioned is never locked out (native - parity: the token is minted at launch, so this is a pre-launch edge). + With no agent token on disk: local requests pass (the token is minted at + launch; a missing one must not lock the owner out), tunnel requests are + refused (503 share_unavailable) — fail closed, never publicly writable. """ method = method.upper() mutating = method in MUTATING @@ -197,7 +198,21 @@ def guard_request( return None expected = _read_secret(project_dir, ".agent-token") if not expected: - return None + # Locally a missing token must not lock the app out (it is minted at + # launch). Through the tunnel it FAILS CLOSED: with no token nothing + # can be checked, and "allow" would make a shared app publicly + # writable. start_tunnel refuses to share without one, too. + if not tunnel: + return None + return 503, { + "a2app": True, + "ok": False, + "code": "share_unavailable", + "message": ( + "This app has no access token, so it cannot be shared. " + "Restart it from CraftBot." + ), + } lowered = {k.lower(): v for k, v in headers.items()} presented = next( @@ -493,7 +508,13 @@ async def _handle(self, request): def _share_exchange(self, request): """Trade the share link's secret for the tunnel UI session, then redirect to the same URL without it (out of the address bar, history - and anything the visitor copies onward).""" + and anything the visitor copies onward). + + Residual exposure, accepted: the first request still carries the + secret in its URL, so it can reach Cloudflare's edge logs and the + tunnel log. It is scoped to one tunnel's lifetime (stop_tunnel + revokes it); moving it into the URL fragment would need a client-side + exchange step the app's own UI does not have.""" from aiohttp import web secret = _read_secret(self.project_dir, ".tunnel-secret") diff --git a/app/agent_app/manager.py b/app/agent_app/manager.py index a81b8941..4c6b5b80 100644 --- a/app/agent_app/manager.py +++ b/app/agent_app/manager.py @@ -4742,6 +4742,26 @@ async def start_tunnel( ) return None + # No agent token = no credential the guards can check. They fail + # closed through the tunnel anyway (guard_request / authorizeCaller), + # but refuse here so the owner is told why instead of handed a link + # that only ever answers 503. The token is minted at launch; a failed + # mint (runner/manager only warn) must never end up shared. + token_file = Path(project.path) / ".agent-token" + try: + has_token = bool(token_file.read_text(encoding="utf-8").strip()) + except Exception: + has_token = False + if not has_token: + logger.error( + f"[AGENT_APP] Refusing to share {project.name}: no agent token " + f"at {token_file}" + ) + raise RuntimeError( + "This app has no access token, so it can't be shared safely. " + "Restart the app and try again." + ) + logger.info("[AGENT_APP] Stopping any existing tunnel...") await self.stop_tunnel(project_id) diff --git a/app/agent_app/ops_manifest.py b/app/agent_app/ops_manifest.py index 275c2cac..e971082a 100644 --- a/app/agent_app/ops_manifest.py +++ b/app/agent_app/ops_manifest.py @@ -166,6 +166,27 @@ def validate_external_manifest(manifest: Any) -> List[str]: return problems +def manifest_warnings(manifest: Any) -> List[str]: + """Non-fatal findings. A `destructive` op declared as GET: local reads + carry no credential by design (guard_request), so a GET op is invocable + cross-site by a plain link or top-level navigation. Declare it POST so + the caller check applies.""" + warnings: List[str] = [] + ops = manifest.get("operations") if isinstance(manifest, dict) else None + for op in ops if isinstance(ops, list) else []: + if not isinstance(op, dict) or op.get("destructive") is not True: + continue + executor = op.get("executor") + method = executor.get("method") if isinstance(executor, dict) else None + if isinstance(method, str) and method.upper() == "GET": + warnings.append( + f"operations[{op.get('name')!r}]: destructive op declared as GET " + "— any page can trigger it with a link (reads carry no " + "credential); declare it POST" + ) + return warnings + + def load_external_manifest(project_dir: Path) -> Tuple[Dict[str, Any], List[str]]: """Read + validate /operations.json. Returns (manifest, problems); an unreadable or unparseable file returns ({}, [reason]).""" diff --git a/app/agent_app/ops_verify.py b/app/agent_app/ops_verify.py index c05a14ce..26c850a4 100644 --- a/app/agent_app/ops_verify.py +++ b/app/agent_app/ops_verify.py @@ -19,6 +19,7 @@ from app.agent_app.ops_manifest import ( load_external_manifest, + manifest_warnings, synthesize_params, ) @@ -135,8 +136,10 @@ async def verify_external_ops( failed = [r for r in results if r["outcome"] not in ("pass", "skipped_destructive")] passed = [r for r in results if r["outcome"] == "pass"] ok = not failed + warnings = manifest_warnings(manifest) return { "status": "success" if ok else "error", + "warnings": warnings, "identity_ok": True, "checked": len(results), "passed": len(passed), @@ -146,6 +149,7 @@ async def verify_external_ops( f"A2App surface verified: {len(passed)} op(s) invoked live, " f"{len(results) - len(passed) - len(failed)} destructive op(s) " "shape-checked." + + "".join(f"\nWarning: {w}" for w in warnings) if ok else ( f"{len(failed)} op(s) failed live verification. Per the " diff --git a/app/agent_app/test_a2app_external.py b/app/agent_app/test_a2app_external.py index 0743a79e..210155ea 100644 --- a/app/agent_app/test_a2app_external.py +++ b/app/agent_app/test_a2app_external.py @@ -23,6 +23,7 @@ _validate_params, ) from app.agent_app.ops_manifest import ( + manifest_warnings, op_route, synthesize_params, validate_external_manifest, @@ -139,6 +140,19 @@ def test_validator() -> None: assert any( "names no declared param" in p for p in validate_external_manifest(ghost) ) + # destructive GET: a warning (reads carry no credential), not an error + risky = { + "opsVersion": 1, + "operations": [ + _op("a.wipe", method="GET", destructive=True, + upstream={"method": "DELETE", "path": "/x"}), + _op("a.list", method="GET", upstream={"method": "GET", "path": "/x"}), + _op("a.del", destructive=True, upstream={"method": "DELETE", "path": "/x"}), + ], + } + assert validate_external_manifest(risky) == [] + warned = manifest_warnings(risky) + assert len(warned) == 1 and "'a.wipe'" in warned[0] and "POST" in warned[0], warned print("validator: OK") @@ -376,6 +390,35 @@ async def post(headers, cookie=None, title="x"): assert titles == ["agent", "agent+origin", "legacy", "ui", "visitor", "remote agent"], titles +async def _no_token_matrix(http, base: str, tmp: Path) -> None: + """No agent token on disk: locally the owner is never locked out, but + through the tunnel the guard FAILS CLOSED — "allow" made a shared app + publicly writable whenever the launch-time mint had failed.""" + create = f"{base}/api/ops/todos/create" + token_file = tmp / ".agent-token" + token_file.write_text("", encoding="utf-8") + try: + for method in ("POST", "DELETE"): + async with http.request( + method, create, json={"title": "open?"}, headers=VIA_TUNNEL + ) as r: + body = await r.json() + assert r.status == 503 and body["code"] == "share_unavailable", ( + method, + r.status, + ) + async with http.get(f"{base}/api/_a2app", headers=VIA_TUNNEL) as r: + assert r.status == 503 + async with http.post( + create, + json={"title": "owner"}, + headers={"Origin": f"http://127.0.0.1:{PROXY_PORT}"}, + ) as r: + assert r.status == 200, "locally a missing token never locks the owner out" + finally: + token_file.write_text(TOKEN, encoding="utf-8") + + async def _proxy_suite(tmp: Path) -> None: import aiohttp @@ -449,6 +492,7 @@ async def _proxy_suite(tmp: Path) -> None: assert r.status == 200 and (await r.json())["title"] == "call John" await _auth_matrix(http, base, tmp, seen) + await _no_token_matrix(http, base, tmp) # unknown op -> 404 envelope, never a silent passthrough async with http.post(f"{base}/api/ops/nope", json={}, headers=auth) as r: diff --git a/app/agent_app/test_a2app_native_auth.py b/app/agent_app/test_a2app_native_auth.py index aa408019..6ac390da 100644 --- a/app/agent_app/test_a2app_native_auth.py +++ b/app/agent_app/test_a2app_native_auth.py @@ -206,6 +206,19 @@ def count(): assert post({**VIA_TUNNEL, "Origin": SHARED}, cookie=shared) in (401, 403) assert _req(base, "GET", create, VIA_TUNNEL, cookie=shared)[0] == 401 + # ── no agent token on disk: the tunnel fails CLOSED, local stays usable ── + # (a failed mint at launch + "share this app" must never be public writes) + token_file = proj / ".agent-token" + token_file.write_text("", encoding="utf-8") + try: + status, _, body = _req(base, "POST", create, VIA_TUNNEL, {"title": "open?"}) + assert status == 503 and body["code"] == "share_unavailable", (status, body) + assert _req(base, "DELETE", create + "/anything", VIA_TUNNEL)[0] == 503 + assert _req(base, "GET", create, VIA_TUNNEL)[0] == 503 + assert post(loopback, title="owner") == 200, "a missing token never locks the owner out" + finally: + token_file.write_text(TOKEN, encoding="utf-8") + def main() -> None: pb = _pinned_pb_binary() diff --git a/app/agent_app/test_tunnel.py b/app/agent_app/test_tunnel.py index dcc5d46c..6a64ed58 100644 --- a/app/agent_app/test_tunnel.py +++ b/app/agent_app/test_tunnel.py @@ -203,6 +203,38 @@ def _check_external_guard(tmp: Path) -> None: print_external = "§4 external-app proxy honours the same grant: OK" +async def _check_tunnel_needs_token(tmp: Path) -> None: + """A failed token mint (launch only warns) + "share this app" used to be + a publicly writable app. start_tunnel refuses, before touching anything.""" + mgr, project, _ = _fixture(tmp) + project.status = "running" + mgr.projects = {project.id: project} + stopped = [] + + async def _stop(pid): + stopped.append(pid) + + mgr.stop_tunnel = _stop + token_file = tmp / ".agent-token" + for content in (None, "", " \n"): + if content is None: + token_file.unlink(missing_ok=True) + else: + token_file.write_text(content, encoding="utf-8") + try: + await mgr.start_tunnel(project.id) + except RuntimeError as e: + assert "access token" in str(e), e + else: + raise AssertionError(f"shared without a token ({content!r})") + assert not stopped, "refused before touching any existing tunnel" + assert not (tmp / ".tunnel-secret").exists() + assert not (tmp / ".tunnel-origin").exists() + + +print_needs_token = "§4b tunnel refuses to share an app with no agent token: OK" + + with tempfile.TemporaryDirectory() as _tmp: asyncio.run(_check_sink(Path(_tmp))) print(print_sink) @@ -219,6 +251,10 @@ def _check_external_guard(tmp: Path) -> None: _check_external_guard(Path(_tmp)) print(print_external) +with tempfile.TemporaryDirectory() as _tmp: + asyncio.run(_check_tunnel_needs_token(Path(_tmp))) + print(print_needs_token) + with tempfile.TemporaryDirectory() as _tmp: _check_serving_port(Path(_tmp)) print(print_port) diff --git a/app/ui_layer/browser/frontend/src/pages/AgentApp/AgentAppPage.tsx b/app/ui_layer/browser/frontend/src/pages/AgentApp/AgentAppPage.tsx index 62c712ab..e823f5bc 100644 --- a/app/ui_layer/browser/frontend/src/pages/AgentApp/AgentAppPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/AgentApp/AgentAppPage.tsx @@ -12,7 +12,7 @@ import { useTheme } from '../../contexts/ThemeContext' import { Button } from '../../components/ui/Button' import { ConfirmModal } from '../../components/ui/ConfirmModal' import { Chat } from '../../components/Chat' -import { getOrCreateIframe, showIframe, hideIframe, removeIframe, postMessageToIframe, ownsProjectWindow } from './iframePool' +import { getOrCreateIframe, showIframe, hideIframe, removeIframe, postMessageToIframe, ownsProjectWindow, frameUrl } from './iframePool' import { ConstructionDock } from './ConstructionDock' import { AgentAppThemeModal, DEFAULT_CUSTOM_COLORS } from './AgentAppThemeModal' import type { AgentAppThemeId, AgentAppCustomColors } from './AgentAppThemeModal' @@ -183,9 +183,10 @@ export function AgentAppPage() { // Version-stamped src: a new deploy broadcasts ready → new readyAt → // the pool navigates the frame past the browser's HTTP cache. Without // this, the iframe kept rendering the pre-deploy build. + const base = frameUrl(project.url) const versionedSrc = project.readyAt - ? `${project.url}${project.url.includes('?') ? '&' : '?'}v=${project.readyAt}` - : project.url + ? `${base}${base.includes('?') ? '&' : '?'}v=${project.readyAt}` + : base getOrCreateIframe(projectId, versionedSrc) const updatePosition = () => { diff --git a/app/ui_layer/browser/frontend/src/pages/AgentApp/iframePool.ts b/app/ui_layer/browser/frontend/src/pages/AgentApp/iframePool.ts index 52532888..5fb8fb96 100644 --- a/app/ui_layer/browser/frontend/src/pages/AgentApp/iframePool.ts +++ b/app/ui_layer/browser/frontend/src/pages/AgentApp/iframePool.ts @@ -39,6 +39,27 @@ function touchAccess(id: string) { } } +/** + * The URL to FRAME an app at. Apps report http://127.0.0.1:, but the + * CraftBot UI usually runs on localhost — and localhost vs 127.0.0.1 are + * different SITES. Framed cross-site, the app's SameSite session cookie is + * neither set nor sent, so every write the app makes (and its WebSocket) is + * refused by the A2App guard. Framing it under the UI's own name keeps it + * same-site (cookies ignore ports). Only 127.0.0.1 → localhost is rewritten: + * localhost resolves to 127.0.0.1 too, while apps never bind [::1]. + */ +export function frameUrl(url: string): string { + if (typeof window === 'undefined' || window.location.hostname !== 'localhost') return url + try { + const u = new URL(url) + if (u.hostname !== '127.0.0.1') return url + u.hostname = 'localhost' + return u.toString() + } catch { + return url + } +} + export function getOrCreateIframe(id: string, src: string): HTMLIFrameElement { let iframe = pool.get(id) if (!iframe) { From c57f04f84458edd39685e38ee23b97b520a76fed Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Tue, 22 Sep 2026 10:39:49 +0100 Subject: [PATCH 4/5] fix: LAN Sharing --- agent-app/blueprint/pb/pb_hooks/_a2app_lib.js | 124 +++- agent-app/blueprint/pb/pb_hooks/_system.pb.js | 47 +- app/agent_app/a2app_proxy.py | 182 +++-- app/agent_app/manager.py | 487 +------------ app/agent_app/sharing.py | 683 ++++++++++++++++++ app/agent_app/test_a2app_external.py | 74 ++ app/agent_app/test_a2app_native_auth.py | 59 ++ app/agent_app/test_sharing.py | 169 +++++ app/agent_app/test_tunnel.py | 77 +- app/ui_layer/adapters/browser_adapter.py | 90 +-- .../frontend/src/locales/en/settings.json | 4 +- .../frontend/src/locales/es/settings.json | 4 +- .../frontend/src/locales/id/settings.json | 4 +- .../frontend/src/locales/ja/settings.json | 4 +- .../frontend/src/locales/ko/settings.json | 4 +- .../frontend/src/locales/zh-CN/settings.json | 4 +- .../frontend/src/locales/zh-TW/settings.json | 4 +- .../src/pages/Settings/AgentAppSettings.tsx | 167 +++-- 18 files changed, 1398 insertions(+), 789 deletions(-) create mode 100644 app/agent_app/sharing.py create mode 100644 app/agent_app/test_sharing.py diff --git a/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js b/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js index 44aab3d2..81061a44 100644 --- a/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js +++ b/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js @@ -511,15 +511,17 @@ function schemaVersion(app) { * HMAC-SHA256-hex the proxy computes). Two INDEPENDENT checks: the origin * guard in _system.pb.js decides which browser pages may talk to the app; * this decides who the caller is. An allowed Origin is never a credential — - * tunnel traffic arrives over loopback and can claim any Origin it likes. + * shared traffic arrives over loopback and can claim any Origin it likes. * * Credentials: the agent token (programs), the UI session cookie (the app's - * own frontend; issued on the page load locally, and ONLY in exchange for - * the share link's secret through the tunnel), or a signed-in PocketBase - * principal. Through the tunnel every request needs one, reads included. + * own frontend; issued on the page load locally, and ONLY in exchange for a + * share link's secret through a share channel), or a signed-in PocketBase + * principal. Through a share channel every request needs one, reads included. */ -var TUNNEL_MARKER_HEADERS = [ +// Each relay stamps what it forwards: Cloudflare's headers through the +// tunnel, X-Forwarded-For through CraftBot's LAN relay. +var REMOTE_MARKER_HEADERS = [ 'Cf-Ray', 'Cf-Connecting-Ip', 'Cf-Visitor', @@ -530,6 +532,9 @@ var TUNNEL_MARKER_HEADERS = [ ]; var LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i; var SHARE_PARAM = 'a2app_share'; +// While open, every share channel (CraftBot's sharing.py) publishes +// .-origin and .-secret. Mirrors a2app_proxy.SHARE_CHANNELS. +var SHARE_CHANNELS = ['tunnel', 'lan']; function readProjectSecret(name) { try { @@ -547,12 +552,34 @@ function headerOf(e, name) { } } -/** Cloudflare stamps every forwarded request with headers a remote caller +/** The grants published right now: [{origin, secret}], read per request so + * closing a channel (deleting its files) takes effect at once. */ +function openShares() { + var shares = []; + for (var i = 0; i < SHARE_CHANNELS.length; i++) { + var origin = readProjectSecret('.' + SHARE_CHANNELS[i] + '-origin'); + var secret = readProjectSecret('.' + SHARE_CHANNELS[i] + '-secret'); + if (origin !== '' || secret !== '') shares.push({ origin: origin, secret: secret }); + } + return shares; +} + +/** A NON-loopback origin the app is currently shared on (the origin guard + * in _system.pb.js checks loopback itself, before paying for file reads). */ +function isSharedOrigin(origin) { + var shares = openShares(); + for (var i = 0; i < shares.length; i++) { + if (shares[i].origin !== '' && origin.toLowerCase() === shares[i].origin.toLowerCase()) return true; + } + return false; +} + +/** A relay stamps every forwarded request with headers a remote caller * cannot strip; a local caller faking one only demotes itself. Go keeps the * Host header in request.host, not in the header map. */ -function isTunnelRequest(e) { - for (var i = 0; i < TUNNEL_MARKER_HEADERS.length; i++) { - if (headerOf(e, TUNNEL_MARKER_HEADERS[i]) !== '') return true; +function isRemoteRequest(e) { + for (var i = 0; i < REMOTE_MARKER_HEADERS.length; i++) { + if (headerOf(e, REMOTE_MARKER_HEADERS[i]) !== '') return true; } var host = ''; try { @@ -568,20 +595,22 @@ function sessionCookieName(token) { return 'a2app_s_' + $security.hs256('a2app-ui:cookie-name:v1', token).slice(0, 12); } -// Stateless: rotating the agent token ends every session; the tunnel value -// folds in .tunnel-secret, which stop_tunnel deletes. -function sessionValue(token, tunnel) { - if (token === '') return ''; - if (!tunnel) return $security.hs256('a2app-ui:local:v1', token); - var secret = readProjectSecret('.tunnel-secret'); - if (secret === '') return ''; +// Stateless: rotating the agent token ends every session; a shared value +// folds in its channel's secret, which closing the channel deletes. +function localSessionValue(token) { + return token === '' ? '' : $security.hs256('a2app-ui:local:v1', token); +} + +function shareSessionValue(token, secret) { + if (token === '' || secret === '') return ''; return $security.hs256('a2app-ui:share:v1:' + secret, token); } // Lax, not Strict: a share link opened from chat is a cross-site navigation, // and Strict would withhold the cookie on the redirect after the exchange. -function sessionCookieHeader(name, value, tunnel) { - return name + '=' + value + '; Path=/; HttpOnly; SameSite=Lax' + (tunnel ? '; Secure' : ''); +// Secure only over https: the LAN relay is plain http. +function sessionCookieHeader(name, value, secure) { + return name + '=' + value + '; Path=/; HttpOnly; SameSite=Lax' + (secure ? '; Secure' : ''); } function cookieOf(e, name) { @@ -607,7 +636,7 @@ function hasPrincipal(e) { /** * THE caller guard. Returns null to proceed, else {status, body}. * Local mutations on /api/collections/ and /api/ops/ need a credential - * (PocketBase's own sign-in flows stay open); through the tunnel, every + * (PocketBase's own sign-in flows stay open); through a share channel, every * request does. The origin half lives in _system.pb.js's first routerUse. */ function authorizeCaller(e) { @@ -620,9 +649,9 @@ function authorizeCaller(e) { return { status: 400, body: { ok: false, error: 'unreadable request' } }; } if (method === 'OPTIONS') return null; // preflights never carry credentials - var tunnel = isTunnelRequest(e); + var remote = isRemoteRequest(e); var mutating = method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE'; - if (!tunnel) { + if (!remote) { if (!mutating) return null; if (path.indexOf('/api/collections/') !== 0 && path.indexOf('/api/ops/') !== 0) return null; if (path.indexOf('/auth-') > 0 || path.indexOf('/request-') > 0) return null; @@ -631,10 +660,10 @@ function authorizeCaller(e) { var token = readProjectSecret('.agent-token'); if (token === '') { // Locally, a missing token must not lock the app out (it is minted at - // launch). Through the tunnel it FAILS CLOSED: no token means no - // credential can be checked, and "allow" would make a shared app - // publicly writable. Mirrors a2app_proxy.guard_request. - if (!tunnel) return null; + // launch). Remotely it FAILS CLOSED: no token means no credential can be + // checked, and "allow" would make a shared app publicly writable. + // Mirrors a2app_proxy.guard_request. + if (!remote) return null; return { status: 503, body: { @@ -648,12 +677,22 @@ function authorizeCaller(e) { // TODO(lui-compat): also accept the legacy X-LUI-Token from older clients. var presented = (headerOf(e, 'X-A2App-Token') || headerOf(e, 'X-LUI-Token')).trim(); if (presented !== '' && $security.equal(presented, token)) return null; - var session = sessionValue(token, tunnel); var cookie = cookieOf(e, sessionCookieName(token)); - if (session !== '' && cookie !== '' && $security.equal(cookie, session)) return null; + if (cookie !== '') { + var sessions = []; + if (remote) { + var shares = openShares(); + for (var i = 0; i < shares.length; i++) sessions.push(shareSessionValue(token, shares[i].secret)); + } else { + sessions.push(localSessionValue(token)); + } + for (var j = 0; j < sessions.length; j++) { + if (sessions[j] !== '' && $security.equal(cookie, sessions[j])) return null; + } + } if (hasPrincipal(e)) return null; - if (tunnel) { + if (remote) { return { status: 401, body: { @@ -675,9 +714,9 @@ function authorizeCaller(e) { } /** - * Share-link exchange: GET ?a2app_share= through the tunnel - * trades the secret for the tunnel UI session and redirects to the same URL - * without it. Returns true when it answered the request. + * Share-link exchange: GET ?a2app_share= through a share + * channel trades that channel's secret for its UI session and redirects to + * the same URL without it. Returns true when it answered the request. */ function handleShareExchange(e) { var method = ''; @@ -688,10 +727,17 @@ function handleShareExchange(e) { } catch { return false; } - if (method !== 'GET' || presented === '' || !isTunnelRequest(e)) return false; + if (method !== 'GET' || presented === '' || !isRemoteRequest(e)) return false; - var secret = readProjectSecret('.tunnel-secret'); - if (secret === '' || !$security.equal(presented, secret)) { + var share = null; + var shares = openShares(); + for (var i = 0; i < shares.length; i++) { + if (shares[i].secret !== '' && $security.equal(presented, shares[i].secret)) { + share = shares[i]; + break; + } + } + if (share === null) { e.json(403, { ok: false, code: 'share_link_invalid', @@ -701,8 +747,9 @@ function handleShareExchange(e) { } var headers = e.response.header(); var token = readProjectSecret('.agent-token'); - var value = sessionValue(token, true); - if (value !== '') headers.add('Set-Cookie', sessionCookieHeader(sessionCookieName(token), value, true)); + var value = shareSessionValue(token, share.secret); + var secure = share.origin.toLowerCase().indexOf('https://') === 0; + if (value !== '') headers.add('Set-Cookie', sessionCookieHeader(sessionCookieName(token), value, secure)); headers.set('Cache-Control', 'no-store'); var q = e.request.url.query(); q.del(SHARE_PARAM); @@ -715,9 +762,9 @@ function handleShareExchange(e) { * boots it (the kit's same-origin fetches then carry it). Anyone who can * reach loopback gets one — everyone who could already read .agent-token. */ function issueLocalSession(e) { - if (isTunnelRequest(e)) return; + if (isRemoteRequest(e)) return; var token = readProjectSecret('.agent-token'); - var value = sessionValue(token, false); + var value = localSessionValue(token); if (value === '') return; var name = sessionCookieName(token); if (cookieOf(e, name) === value) return; @@ -729,6 +776,7 @@ module.exports = { authorizeCaller: authorizeCaller, handleShareExchange: handleShareExchange, issueLocalSession: issueLocalSession, + isSharedOrigin: isSharedOrigin, describeApp: describeApp, fieldsOf: fieldsOf, protocolType: protocolType, diff --git a/agent-app/blueprint/pb/pb_hooks/_system.pb.js b/agent-app/blueprint/pb/pb_hooks/_system.pb.js index 245e4758..43b2c819 100644 --- a/agent-app/blueprint/pb/pb_hooks/_system.pb.js +++ b/agent-app/blueprint/pb/pb_hooks/_system.pb.js @@ -25,14 +25,15 @@ * preflight for a destructive route is no longer approved. Direct clients * (curl, the CLI, an agent) are unaffected — they were never the threat. * - * Policy: loopback origins, PLUS the one public origin the host publishes in - * `/.tunnel-origin` while the user is deliberately sharing this app - * (AgentAppManager.start_tunnel writes it, stop_tunnel deletes it). Loopback - * alone did not make sharing safe, it made it impossible: browsers send - * `Origin` on same-origin writes too, so through a tunnel the app LOADED (a - * GET carries no Origin) and then answered 403 to every save. The file is read - * per request, so the grant lasts exactly as long as the tunnel does and needs - * no app restart at either end — and with no tunnel up, the policy is + * Policy: loopback origins, PLUS the origin of each channel the host is + * deliberately sharing this app on — `/.tunnel-origin` (public link) + * and `/.lan-origin` (private LAN link), written when the channel + * opens and deleted when it closes (CraftBot's sharing.py). Loopback alone + * did not make sharing safe, it made it impossible: browsers send `Origin` on + * same-origin writes too, so through a share the app LOADED (a GET carries no + * Origin) and then answered 403 to every save. The files are read per + * request, so a grant lasts exactly as long as its channel and needs no app + * restart at either end — and with nothing shared, the policy is * loopback-only, exactly as before. * * NOTE FOR EDITORS: hook callbacks run in isolated VMs that CANNOT see this @@ -42,19 +43,9 @@ */ routerUse((e) => { - // Inlined per the NOTE above — callbacks cannot see this file's scope, and - // cannot see each other's either, so this lives once per callback that needs - // it. Called late, so only a NON-loopback origin ever costs a file read. + // Called late, so only a NON-loopback origin ever costs the share-file reads. function isSharedOrigin(candidate) { - var shared = ''; - try { - shared = toString( - $os.readFile($filepath.join(__hooks, '..', '..', '.tunnel-origin')) - ).trim(); - } catch { - return false; // no file = not sharing = loopback only - } - return shared !== '' && candidate.toLowerCase() === shared.toLowerCase(); + return require(`${__hooks}/_a2app_lib.js`).isSharedOrigin(candidate); } // Must run BEFORE e.next(): headers are flushed with the first body byte, so @@ -137,8 +128,8 @@ routerUse((e) => { ok: false, error: 'forbidden origin: ' + origin, hint: - 'This app accepts writes from loopback origins, and from the shared ' + - 'origin in .tunnel-origin while sharing is switched on.', + 'This app accepts writes from loopback origins, and from the origin ' + + 'of each share link (public or LAN) while that link is switched on.', }); } return e.next(); @@ -282,16 +273,8 @@ routerAdd('POST', '/api/_console', (e) => { origin = ''; } if (origin !== '' && !ALLOWED_ORIGIN.test(origin)) { - // Read late: only a NON-loopback origin ever costs a file read. - let sharedOrigin = ''; - try { - sharedOrigin = toString( - $os.readFile($filepath.join(__hooks, '..', '..', '.tunnel-origin')) - ).trim(); - } catch { - sharedOrigin = ''; - } - if (sharedOrigin === '' || origin.toLowerCase() !== sharedOrigin.toLowerCase()) { + // Checked late: only a NON-loopback origin ever costs the share-file reads. + if (!require(`${__hooks}/_a2app_lib.js`).isSharedOrigin(origin)) { return e.json(403, { ok: false, error: 'forbidden origin' }); } } diff --git a/app/agent_app/a2app_proxy.py b/app/agent_app/a2app_proxy.py index 0e53277e..8851cc33 100644 --- a/app/agent_app/a2app_proxy.py +++ b/app/agent_app/a2app_proxy.py @@ -21,9 +21,10 @@ check requires every mutation to carry a credential — X-A2App-Token from the project's .agent-token (programs, the agent), or the UI session cookie the app's own browser UI is issued. An allowed Origin is never a credential: -tunnel traffic arrives over loopback and can claim any Origin it likes. -Through a tunnel, every request needs the credential, reads included; the -UI session there is only issued in exchange for the share link's secret. +shared traffic arrives over loopback and can claim any Origin it likes. +Through a share channel (sharing.py: the public tunnel or the private LAN +relay), every request needs the credential, reads included; the UI session +there is only issued in exchange for that channel's share-link secret. Ops are (re)read from operations.json on every request, like the native describe, so the surface can never drift from the file on disk. """ @@ -32,6 +33,7 @@ import hmac import json import re +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Mapping, Optional, Tuple @@ -69,12 +71,14 @@ # ── caller authentication (shared with the native guard in _a2app_lib.js) ── # -# Local vs tunnel cannot be told apart by Origin (a tunnelled caller can send -# a loopback one) nor by peer address (cloudflared connects from loopback). -# It CAN be told apart by what Cloudflare adds to every request it forwards — -# headers a remote caller cannot strip. The test is fail-safe: a local caller -# that fakes one only demotes itself to tunnel rules. -TUNNEL_MARKER_HEADERS = ( +# Local vs remote cannot be told apart by Origin (a remote caller can send a +# loopback one) nor by peer address (every share channel's relay connects +# from loopback). It CAN be told apart by what each relay adds to every +# request it forwards — headers a remote caller cannot strip: Cloudflare's +# stamps through the tunnel, X-Forwarded-For through the LAN relay. The test +# is fail-safe: a local caller that fakes one only demotes itself to remote +# rules. +REMOTE_MARKER_HEADERS = ( "cf-ray", "cf-connecting-ip", "cf-visitor", @@ -85,6 +89,11 @@ ) LOOPBACK_HOST = re.compile(r"^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$", re.I) SHARE_PARAM = "a2app_share" +# While open, every share channel (sharing.py) publishes the one origin +# browsers use through it (`.-origin`) and the secret its share link +# trades for a session (`.-secret`). The guards read only these files: +# they never know how a channel is transported, only which grants are open. +SHARE_CHANNELS = ("tunnel", "lan") TOKEN_HEADERS = ("X-A2App-Token", "X-LUI-Token") # TODO(lui-compat): legacy @@ -101,29 +110,66 @@ def _read_secret(project_dir: Path, name: str) -> str: return "" +@dataclass(frozen=True) +class OpenShare: + """One open share channel's grant, as the guards see it.""" + + origin: str + secret: str + + @property + def secure(self) -> bool: + """https channels get a Secure cookie. The LAN relay is plain http, + where a Secure cookie would never be sent back.""" + return self.origin.lower().startswith("https://") + + +def open_shares(project_dir: Path) -> List[OpenShare]: + """The grants published right now. Read per request, like the native + guard, so sharing starts and stops without restarting anything, and + closing a channel (deleting its files) takes effect at once.""" + shares = [] + for name in SHARE_CHANNELS: + origin = _read_secret(project_dir, f".{name}-origin") + secret = _read_secret(project_dir, f".{name}-secret") + if origin or secret: + shares.append(OpenShare(origin, secret)) + return shares + + +def match_share(project_dir: Path, presented: str) -> Optional[OpenShare]: + """The open share whose link secret was presented (constant-time).""" + if not presented: + return None + for share in open_shares(project_dir): + if share.secret and hmac.compare_digest( + presented.encode(), share.secret.encode() + ): + return share + return None + + def origin_allowed(project_dir: Path, origin: str) -> bool: - """Loopback, or the one public origin the host is currently sharing. - - AgentAppManager.start_tunnel writes `.tunnel-origin` and stop_tunnel - deletes it, so the grant lasts exactly as long as the tunnel. Read per - request for the same reason the native guard does: sharing starts and - stops without restarting anything. Loopback-only was not a safe default - for a shared app, it was a broken one — browsers send `Origin` on - same-origin writes too, so through a tunnel every write was refused. - This decides which browser pages may TALK to the app; it authenticates - nobody (see guard_request). + """Loopback, or the origin of a channel the app is being shared on. + + Loopback-only was not a safe default for a shared app, it was a broken + one — browsers send `Origin` on same-origin writes too, so through a + share every write was refused. This decides which browser pages may + TALK to the app; it authenticates nobody (see guard_request). """ if LOOPBACK_ORIGIN.match(origin): return True - shared = _read_secret(project_dir, ".tunnel-origin") - return bool(shared) and origin.lower() == shared.lower() + return any( + s.origin and origin.lower() == s.origin.lower() + for s in open_shares(project_dir) + ) -def is_tunnel_request(headers: Mapping[str, str]) -> bool: - """True when the request came in through the share tunnel (or cannot be +def is_remote_request(headers: Mapping[str, str]) -> bool: + """True when the request came in through a share channel (or cannot be proven local). Local = no forwarding marker AND a loopback Host.""" lowered = {k.lower() for k in headers.keys()} - if any(h in lowered for h in TUNNEL_MARKER_HEADERS): + if any(h in lowered for h in REMOTE_MARKER_HEADERS): return True host = next((v for k, v in headers.items() if k.lower() == "host"), "") return not LOOPBACK_HOST.match(host.strip()) @@ -135,31 +181,28 @@ def session_cookie_name(agent_token: str) -> str: return "a2app_s_" + _hs256(agent_token, "a2app-ui:cookie-name:v1")[:12] -def session_value(project_dir: Path, agent_token: str, tunnel: bool) -> str: - """The UI session credential for this ingress, or "" if none can exist. +# UI session values: stateless (derived, never stored), so rotating the agent +# token ends every session. Local and shared values differ, so a local cookie +# is never a remote credential; a shared value folds in its channel's secret, +# which closing the channel deletes — every session on it dies with it. +def local_session_value(agent_token: str) -> str: + return _hs256(agent_token, "a2app-ui:local:v1") if agent_token else "" - Stateless (derived, never stored): rotating the agent token ends every - session. Local and tunnel values differ, so a local cookie is never a - tunnel credential; the tunnel value folds in `.tunnel-secret`, which - stop_tunnel deletes — every shared session dies with the tunnel.""" - if not agent_token: - return "" - if not tunnel: - return _hs256(agent_token, "a2app-ui:local:v1") - secret = _read_secret(project_dir, ".tunnel-secret") - if not secret: + +def share_session_value(agent_token: str, secret: str) -> str: + if not (agent_token and secret): return "" return _hs256(agent_token, "a2app-ui:share:v1:" + secret) -def session_cookie_header(name: str, value: str, tunnel: bool) -> str: +def session_cookie_header(name: str, value: str, secure: bool) -> str: # Lax, not Strict: a share link opened from chat is a cross-site # navigation, and Strict would withhold the cookie on the redirect that # follows the exchange. Writes do not lean on SameSite — the origin check # and the per-ingress value do that work. return ( f"{name}={value}; Path=/; HttpOnly; SameSite=Lax" - + ("; Secure" if tunnel else "") + + ("; Secure" if secure else "") ) @@ -174,11 +217,12 @@ def guard_request( Two independent checks, in order: 1. Origin — a foreign Origin on a mutation is refused (403). Reads pass: for those, withholding the CORS grant is the browser-side defence. - 2. Caller — a mutation, or ANY request through the tunnel, must carry - a credential: the agent token (constant-time compare) or this - ingress's UI session cookie. The Origin plays no part here. + 2. Caller — a mutation, or ANY request through a share channel, must + carry a credential: the agent token (constant-time compare) or this + ingress's UI session cookie (the local one, or one per open share). + The Origin plays no part here. With no agent token on disk: local requests pass (the token is minted at - launch; a missing one must not lock the owner out), tunnel requests are + launch; a missing one must not lock the owner out), remote requests are refused (503 share_unavailable) — fail closed, never publicly writable. """ method = method.upper() @@ -192,17 +236,17 @@ def guard_request( "message": "Cross-origin writes are not allowed.", } - tunnel = is_tunnel_request(headers) + remote = is_remote_request(headers) # Preflights never carry credentials (browsers strip them by spec). - if method == "OPTIONS" or not (mutating or tunnel): + if method == "OPTIONS" or not (mutating or remote): return None expected = _read_secret(project_dir, ".agent-token") if not expected: # Locally a missing token must not lock the app out (it is minted at - # launch). Through the tunnel it FAILS CLOSED: with no token nothing - # can be checked, and "allow" would make a shared app publicly - # writable. start_tunnel refuses to share without one, too. - if not tunnel: + # launch). Remotely it FAILS CLOSED: with no token nothing can be + # checked, and "allow" would make a shared app publicly writable. + # ShareChannel.open refuses to share without one, too. + if not remote: return None return 503, { "a2app": True, @@ -220,12 +264,19 @@ def guard_request( ).strip() if presented and hmac.compare_digest(presented.encode(), expected.encode()): return None - session = session_value(project_dir, expected, tunnel) cookie = cookies.get(session_cookie_name(expected), "") - if session and cookie and hmac.compare_digest(cookie.encode(), session.encode()): + if remote: + sessions = [ + share_session_value(expected, s.secret) for s in open_shares(project_dir) + ] + else: + sessions = [local_session_value(expected)] + if cookie and any( + v and hmac.compare_digest(cookie.encode(), v.encode()) for v in sessions + ): return None - if tunnel: + if remote: return 401, { "a2app": True, "ok": False, @@ -482,7 +533,7 @@ async def _handle(self, request): if ( request.method == "GET" and SHARE_PARAM in request.query - and is_tunnel_request(request.headers) + and is_remote_request(request.headers) ): return self._share_exchange(request) own = ( @@ -506,24 +557,19 @@ async def _handle(self, request): return await self._passthrough(request) def _share_exchange(self, request): - """Trade the share link's secret for the tunnel UI session, then + """Trade a share link's secret for that channel's UI session, then redirect to the same URL without it (out of the address bar, history and anything the visitor copies onward). Residual exposure, accepted: the first request still carries the secret in its URL, so it can reach Cloudflare's edge logs and the - tunnel log. It is scoped to one tunnel's lifetime (stop_tunnel - revokes it); moving it into the URL fragment would need a client-side - exchange step the app's own UI does not have.""" + tunnel log. It is scoped to one channel's lifetime (closing the + channel revokes it); moving it into the URL fragment would need a + client-side exchange step the app's own UI does not have.""" from aiohttp import web - secret = _read_secret(self.project_dir, ".tunnel-secret") - presented = request.query.get(SHARE_PARAM, "") - if not ( - secret - and presented - and hmac.compare_digest(presented.encode(), secret.encode()) - ): + share = match_share(self.project_dir, request.query.get(SHARE_PARAM, "")) + if share is None: return self._json( request, 403, @@ -540,10 +586,10 @@ def _share_exchange(self, request): rest = [(k, v) for k, v in request.query.items() if k != SHARE_PARAM] resp = web.HTTPFound(str(request.rel_url.with_query(rest))) token = _read_secret(self.project_dir, ".agent-token") - value = session_value(self.project_dir, token, tunnel=True) + value = share_session_value(token, share.secret) if value: resp.headers["Set-Cookie"] = session_cookie_header( - session_cookie_name(token), value, tunnel=True + session_cookie_name(token), value, secure=share.secure ) resp.headers["Cache-Control"] = "no-store" return resp @@ -553,16 +599,16 @@ def _local_session_cookie(self, request) -> Optional[str]: valid session. Anyone who can reach loopback gets one — which is everyone who could already read .agent-token, so it grants nothing new; what it replaces is trusting a forgeable Origin header.""" - if is_tunnel_request(request.headers): + if is_remote_request(request.headers): return None token = _read_secret(self.project_dir, ".agent-token") - value = session_value(self.project_dir, token, tunnel=False) + value = local_session_value(token) if not value: return None name = session_cookie_name(token) if request.cookies.get(name) == value: return None - return session_cookie_header(name, value, tunnel=False) + return session_cookie_header(name, value, secure=False) # ── A2App endpoints ──────────────────────────────────────────────────── diff --git a/app/agent_app/manager.py b/app/agent_app/manager.py index 4c6b5b80..b2e921d2 100644 --- a/app/agent_app/manager.py +++ b/app/agent_app/manager.py @@ -31,6 +31,7 @@ from app import node_runtime from app.agent_app import marketplace_source +from app.agent_app.sharing import SHARE_STATE_FILES, ShareError, SharingService try: from loguru import logger @@ -194,11 +195,6 @@ class AgentAppProject: # binds; the A2App proxy holds `port` in front of it (NOT serialized — # reallocated at every launch). internal_port: Optional[int] = None - tunnel_url: Optional[str] = None # Public tunnel URL (NOT serialized) - tunnel_process: Optional[subprocess.Popen] = None # Tunnel process (NOT serialized) - # Open file object the tunnel process writes into (NOT serialized). Held - # so it can be closed when the tunnel stops — see start_tunnel. - tunnel_log: Optional[Any] = None process: Optional[subprocess.Popen] = None # Frontend process def to_dict(self) -> Dict[str, Any]: @@ -230,7 +226,6 @@ def to_dict(self) -> Dict[str, Any]: "appRuntime": self.app_runtime, "craftbotVersion": self.craftbot_version, "agentAppVersion": 2, - "tunnelUrl": self.tunnel_url, } @@ -279,6 +274,10 @@ def __init__(self, workspace_root: Path): # so every kill-by-port on a project port must stop the proxy first. self._external_proxies: Dict[str, Any] = {} + # Share channels (private LAN link, public tunnel link): every way a + # running app is reached from off this machine. See sharing.py. + self.sharing = SharingService(self._terminate_process) + # Ensure workspace directory exists self.agent_app_dir = self.workspace_root / "agent_app" self.agent_app_dir.mkdir(parents=True, exist_ok=True) @@ -950,36 +949,9 @@ def _load_projects(self) -> None: app_runtime=project_data.get("appRuntime"), craftbot_version=project_data.get("craftbotVersion"), ) - # Check if saved tunnel URL is still reachable - saved_tunnel = project_data.get("tunnelUrl") - if saved_tunnel: - try: - import urllib.error - import urllib.request - - req = urllib.request.Request( - saved_tunnel, method="HEAD" - ) - try: - urllib.request.urlopen(req, timeout=3) - except urllib.error.HTTPError as he: - # 401 is the app's own answer to a - # visitor without a share session: the - # tunnel is up. Anything else is dead. - if he.code != 401: - raise - project.tunnel_url = saved_tunnel - logger.info( - f"[AGENT_APP] Tunnel still active for '{project.name}': {saved_tunnel}" - ) - except Exception: - logger.info( - f"[AGENT_APP] Tunnel expired for '{project.name}', clearing" - ) - project.tunnel_url = None - # The app must stop trusting an origin that no - # longer reaches it. - self._publish_tunnel_origin(project, None) + # Share channels: keep a grant whose transport + # survived the restart, revoke the rest. + self.sharing.restore(project) self.projects[project.id] = project # The live port is sticky: reserved for the project's # whole lifetime, not a single boot, so the allocator @@ -3166,7 +3138,7 @@ async def _convert_tree( "token.json", ".superuser", ".agent-token", - ".tunnel-secret", + *SHARE_STATE_FILES, ".jwt_secret", ".npmrc", ".netrc", @@ -3338,8 +3310,8 @@ async def _import_project_tree( # Never trust shipped credentials or runtime state. (dest / ".superuser").unlink(missing_ok=True) - (dest / ".tunnel-origin").unlink(missing_ok=True) - (dest / ".tunnel-secret").unlink(missing_ok=True) + for name in SHARE_STATE_FILES: + (dest / name).unlink(missing_ok=True) # Rewrite identity + port (pipeline start command embeds the port). old_port = manifest.get("port") @@ -4439,8 +4411,8 @@ async def delete_project( f"[AGENT_APP:BACKUP] backup cleanup failed for {project_id}: {e}" ) - # Stop tunnel if active - await self.stop_tunnel(project_id) + # Close every share link + await self.sharing.close_all(project) # Stop if running if project.status == "running": @@ -4581,10 +4553,9 @@ def export_project_zip(self, project_id: str) -> Path: "credentials.json", "token.json", ".jwt_secret", - # Host-local, tunnel-lifetime state: an exported app must not + # Host-local, share-lifetime state: an exported app must not # arrive somewhere else already trusting a foreign origin. - ".tunnel-origin", - ".tunnel-secret", + *SHARE_STATE_FILES, } with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: @@ -4610,26 +4581,9 @@ def get_project_url(self, project_id: str) -> Optional[str]: return None # ------------------------------------------------------------------ - # LAN & Tunnel sharing + # Sharing (private LAN link / public tunnel link) — see sharing.py # ------------------------------------------------------------------ - @staticmethod - def get_lan_ip() -> Optional[str]: - """Get the machine's LAN IP address.""" - try: - # Connect to a public IP to determine the right interface - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(1) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() - return ip - except Exception: - try: - return socket.gethostbyname(socket.gethostname()) - except Exception: - return None - @staticmethod def _serving_port(project: AgentAppProject) -> Optional[int]: """The port the app ACTUALLY listens on: always `project.port`. @@ -4642,411 +4596,28 @@ def _serving_port(project: AgentAppProject) -> Optional[int]: served from it any more.""" return project.port - def get_lan_url(self, project_id: str) -> Optional[str]: - """Get the LAN-accessible URL for a running project. - - One port for everything: the app serves its API and its frontend - static files from the same listener. - """ + async def open_share(self, project_id: str, channel: str) -> str: + """Open a share channel ("lan" | "tunnel") for a running project and + return its share link. Raises ShareError with an owner-facing reason.""" project = self.projects.get(project_id) if not project or project.status != "running": - return None + raise ShareError("Start the app before sharing it.") port = self._serving_port(project) if not port: - return None - ip = self.get_lan_ip() - if not ip or ip.startswith("127."): - return None - return f"http://{ip}:{port}" - - # Cloudflared binary download URLs per platform - _CLOUDFLARED_URLS = { - "win32": "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe", - "darwin": "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz", - "linux": "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64", - } - - def _get_cloudflared_path(self) -> Optional[str]: - """Find cloudflared — check PATH first, then our local bin directory.""" - system_path = shutil.which("cloudflared") - if system_path: - return system_path - # Check our local bin - import sys - - ext = ".exe" if sys.platform == "win32" else "" - local_bin = Path(__file__).parent.parent / "bin" / f"cloudflared{ext}" - if local_bin.exists(): - return str(local_bin) - return None - - async def _ensure_cloudflared(self) -> Optional[str]: - """Find cloudflared or auto-install it. Returns the binary path or None.""" - path = self._get_cloudflared_path() - if path: - return path - - logger.info("[AGENT_APP] cloudflared not found, auto-installing...") - import sys - import urllib.request - - platform_key = sys.platform - if platform_key not in self._CLOUDFLARED_URLS: - logger.error(f"[AGENT_APP] Unsupported platform: {platform_key}") - return None - - bin_dir = Path(__file__).parent.parent / "bin" - bin_dir.mkdir(parents=True, exist_ok=True) - ext = ".exe" if platform_key == "win32" else "" - target = bin_dir / f"cloudflared{ext}" - - try: - url = self._CLOUDFLARED_URLS[platform_key] - req = urllib.request.Request(url, headers={"User-Agent": "CraftBot"}) - resp = urllib.request.urlopen(req, timeout=60) - - if platform_key == "darwin": - import tarfile - import io - - with tarfile.open(fileobj=io.BytesIO(resp.read()), mode="r:gz") as tar: - for member in tar.getmembers(): - if "cloudflared" in member.name: - f = tar.extractfile(member) - if f: - target.write_bytes(f.read()) - break - else: - target.write_bytes(resp.read()) - - if platform_key != "win32": - target.chmod(0o755) - - logger.info(f"[AGENT_APP] cloudflared installed at {target}") - return str(target) - except Exception as e: - logger.error(f"[AGENT_APP] Failed to download cloudflared: {e}") - if target.exists(): - target.unlink() - return None + raise ShareError("The app has no port to share.") + return await self.sharing.open(project, channel, port) - async def start_tunnel( - self, project_id: str, provider: str = "cloudflared" - ) -> Optional[str]: - """Start a cloudflare tunnel for remote access. Returns the public URL.""" - logger.info(f"[AGENT_APP] start_tunnel called for {project_id}") + async def close_share(self, project_id: str, channel: str) -> None: project = self.projects.get(project_id) - if not project or project.status != "running": - logger.warning( - f"[AGENT_APP] Cannot start tunnel: project={project is not None}, status={project.status if project else 'N/A'}" - ) - return None - - # No agent token = no credential the guards can check. They fail - # closed through the tunnel anyway (guard_request / authorizeCaller), - # but refuse here so the owner is told why instead of handed a link - # that only ever answers 503. The token is minted at launch; a failed - # mint (runner/manager only warn) must never end up shared. - token_file = Path(project.path) / ".agent-token" - try: - has_token = bool(token_file.read_text(encoding="utf-8").strip()) - except Exception: - has_token = False - if not has_token: - logger.error( - f"[AGENT_APP] Refusing to share {project.name}: no agent token " - f"at {token_file}" - ) - raise RuntimeError( - "This app has no access token, so it can't be shared safely. " - "Restart the app and try again." - ) - - logger.info("[AGENT_APP] Stopping any existing tunnel...") - await self.stop_tunnel(project_id) - - # Only kill orphans on first tunnel start (no other tunnels active) - other_tunnels = any( - p.tunnel_process is not None and p.id != project_id - for p in self.projects.values() - ) - if not other_tunnels: - logger.info( - "[AGENT_APP] No other tunnels active, cleaning orphan cloudflared processes..." - ) - try: - if os.name == "nt": - subprocess.run( - [ - "powershell", - "-Command", - "Stop-Process -Name cloudflared -Force -ErrorAction SilentlyContinue", - ], - capture_output=True, - timeout=5, - creationflags=subprocess.CREATE_NO_WINDOW - if hasattr(subprocess, "CREATE_NO_WINDOW") - else 0, - ) - else: - subprocess.run(["pkill", "-f", "cloudflared"], capture_output=True) - await asyncio.sleep(1) - except Exception: - pass - - port = self._serving_port(project) - if not port: - return None - - cloudflared = await self._ensure_cloudflared() - if not cloudflared: - logger.error("[AGENT_APP] cloudflared binary not found") - return None - - # cloudflared writes to stderr for the WHOLE life of the tunnel, not - # just at startup. Piping that into this process and then not draining - # it — which is what "find the URL, return from the reader thread" - # did — fills the OS pipe buffer (4 KB by default on Windows) and - # cloudflared then BLOCKS forever on its next write. The tunnel stops - # proxying while the process still looks perfectly alive, so remote - # visitors hang until their client times out, and every byte that would - # explain why is stuck unread in that buffer. A file sink has no such - # backpressure, and doubles as the log this had no way to produce. - log_handle, log_path, log_offset = self._open_tunnel_log(project, port) - if log_handle is None: - logger.error("[AGENT_APP] No writable location for the cloudflared log") - return None - - # 127.0.0.1, NOT localhost: PocketBase binds --http=127.0.0.1: - # (runner.start) and the external-app proxy binds the same, so neither - # ever listens on ::1. cloudflared resolves 'localhost' to ::1 first on - # Windows and got "connectex: No connection could be made" on every - # single request — the tunnel came up healthy, announced its URL, and - # then refused every visitor. - origin_url = f"http://127.0.0.1:{port}" - logger.info( - f"[AGENT_APP] Starting cloudflared: {cloudflared} tunnel " - f"--url {origin_url} (log: {log_path})" - ) - proc = subprocess.Popen( - [cloudflared, "tunnel", "--url", origin_url], - stdout=log_handle, - stderr=subprocess.STDOUT, - creationflags=subprocess.CREATE_NO_WINDOW - if os.name == "nt" and hasattr(subprocess, "CREATE_NO_WINDOW") - else 0, - ) - logger.info(f"[AGENT_APP] cloudflared started, PID={proc.pid}, parsing URL...") - url = await self._parse_cloudflare_url(proc, log_path, log_offset) - logger.info(f"[AGENT_APP] cloudflared URL parse result: {url}") - - if url: - project.tunnel_process = proc - project.tunnel_log = log_handle - project.tunnel_url = url - self._publish_tunnel_origin(project, url) - self._save_projects() - logger.info(f"[AGENT_APP] Tunnel started for {project.name}: {url}") - # Hand back the SHARE link: the bare tunnel URL admits nobody. - return self.get_tunnel_share_url(project_id) - else: - self._terminate_process(proc) - self._close_tunnel_log(log_handle) - logger.error( - f"[AGENT_APP] Failed to get tunnel URL; cloudflared's own " - f"output is in {log_path}" - ) - return None + if project: + await self.sharing.close(project, channel) - async def stop_tunnel(self, project_id: str) -> None: - """Stop the tunnel for a project.""" + def share_links(self, project_id: str) -> Dict[str, Optional[str]]: + """{channel: share link, or None while that channel is closed}.""" project = self.projects.get(project_id) if not project: - return - if project.tunnel_process: - self._terminate_process(project.tunnel_process) - project.tunnel_process = None - self._close_tunnel_log(project.tunnel_log) - project.tunnel_log = None - project.tunnel_url = None - self._publish_tunnel_origin(project, None) - self._save_projects() - logger.info(f"[AGENT_APP] Tunnel stopped for {project.name}") - - @staticmethod - def _tunnel_log_path(project: AgentAppProject) -> Path: - return Path(project.path) / "logs" / "cloudflared.log" - - def _open_tunnel_log( - self, project: AgentAppProject, port: int - ) -> Tuple[Optional[Any], Path, int]: - """Open cloudflared's output sink. Returns (handle, path, offset). - - The sink is not optional — it is both the tunnel's only log and the - only place the public URL is announced — so an unwritable project - directory falls back to the temp dir rather than failing the share. - """ - candidates = [ - self._tunnel_log_path(project), - Path(tempfile.gettempdir()) / f"cloudflared-{project.id}.log", - ] - for path in candidates: - try: - path.parent.mkdir(parents=True, exist_ok=True) - # Append across restarts, but never grow without bound: this - # file collects everything cloudflared logs while sharing. - too_big = path.exists() and path.stat().st_size > 2_000_000 - handle = open( - path, - "w" if too_big else "a", - encoding="utf-8", - errors="replace", - ) - handle.write( - f"\n=== cloudflared start " - f"{datetime.now().isoformat(timespec='seconds')} " - f"port={port} ===\n" - ) - handle.flush() - return handle, path, path.stat().st_size - except Exception as e: - logger.warning(f"[AGENT_APP] Tunnel log unusable at {path}: {e}") - return None, candidates[-1], 0 - - @staticmethod - def _close_tunnel_log(handle: Optional[Any]) -> None: - if handle is None: - return - try: - handle.close() - except Exception: - pass - - @staticmethod - def _tunnel_origin_file(project: AgentAppProject) -> Path: - return Path(project.path) / ".tunnel-origin" - - def _publish_tunnel_origin( - self, project: AgentAppProject, url: Optional[str] - ) -> None: - """Tell the app which public origin to trust, or that there is none. - - The app's origin guard (pb/pb_hooks/_system.pb.js) allows loopback - origins only — right for a loopback app, fatal for a shared one: - browsers send `Origin` on same-origin writes too, so through a tunnel - the app LOADED (GET carries no Origin) and then 403'd every save. The - guard reads this file per request, so the grant appears and disappears - with the tunnel, with no app restart in between. - """ - path = self._tunnel_origin_file(project) - try: - if url: - origin = url.rstrip("/") - path.write_text(origin + "\n", encoding="utf-8") - logger.info(f"[AGENT_APP] Shared origin published: {origin}") - elif path.exists(): - path.unlink() - logger.info(f"[AGENT_APP] Shared origin revoked for {project.name}") - except Exception as e: - logger.warning(f"[AGENT_APP] Could not update {path.name}: {e}") - self._publish_tunnel_secret(project, bool(url)) - - @staticmethod - def _tunnel_secret_file(project: AgentAppProject) -> Path: - return Path(project.path) / ".tunnel-secret" - - def _publish_tunnel_secret(self, project: AgentAppProject, on: bool) -> None: - """Mint (on) or revoke (off) the share link's secret. - - Through a tunnel the origin grant above authenticates nobody — every - request needs a credential, and a visitor's browser only gets one by - trading this secret (?a2app_share=, see a2app_proxy.guard_request and - _a2app_lib.js authorizeCaller). Fresh per tunnel start; deleting it - ends every shared session at once. An existing secret is kept when - the same tunnel is re-published, so links already sent keep working. - """ - path = self._tunnel_secret_file(project) - try: - if on: - if not path.exists() or not path.read_text(encoding="utf-8").strip(): - path.write_text(secrets.token_urlsafe(32), encoding="utf-8") - try: - os.chmod(path, 0o600) - except Exception: - pass - elif path.exists(): - path.unlink() - except Exception as e: - logger.warning(f"[AGENT_APP] Could not update {path.name}: {e}") - - def get_tunnel_share_url(self, project_id: str) -> Optional[str]: - """The link to hand out: the tunnel URL plus the share secret. The - bare tunnel URL alone admits nobody.""" - project = self.projects.get(project_id) - if not project or not project.tunnel_url: - return None - try: - secret = ( - self._tunnel_secret_file(project).read_text(encoding="utf-8").strip() - ) - except Exception: - secret = "" - if not secret: - return project.tunnel_url - return f"{project.tunnel_url.rstrip('/')}/?a2app_share={secret}" - - async def _parse_cloudflare_url( - self, - proc: subprocess.Popen, - log_path: Path, - start_offset: int = 0, - timeout: int = 30, - ) -> Optional[str]: - """Wait for cloudflared to announce its public URL in its log file. - - Tails the file rather than reading the process pipes — see the note in - start_tunnel about the pipe-buffer deadlock that cost us the tunnel. - """ - pattern = re.compile(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com") - deadline = time.time() + timeout - offset = start_offset - seen = "" - - while True: - # Sample liveness BEFORE reading, so a process that dies between - # the two still gets its final bytes examined. - exited = proc.poll() is not None - try: - with open(log_path, "r", encoding="utf-8", errors="replace") as fh: - fh.seek(offset) - seen += fh.read() - offset = fh.tell() - except FileNotFoundError: - pass - - match = pattern.search(seen) - if match: - logger.info(f"[AGENT_APP] Parsed cloudflare URL: {match.group(0)}") - return match.group(0) - - # cloudflared boxes the URL inside an ASCII banner, so it can land - # split across two reads: keep a tail long enough to re-match. - if len(seen) > 8192: - seen = seen[-1024:] - - if exited: - logger.error( - f"[AGENT_APP] cloudflared exited (code {proc.returncode}) " - f"before announcing a URL; see {log_path}" - ) - return None - if time.time() >= deadline: - logger.error( - f"[AGENT_APP] Failed to parse cloudflare URL within " - f"{timeout}s; see {log_path}" - ) - return None - await asyncio.sleep(0.3) + return {name: None for name in self.sharing.channels} + return self.sharing.links(project) async def auto_launch_projects(self, project_ids: List[str] = None) -> None: """Auto-launch projects on startup. diff --git a/app/agent_app/sharing.py b/app/agent_app/sharing.py new file mode 100644 index 00000000..ac3660f3 --- /dev/null +++ b/app/agent_app/sharing.py @@ -0,0 +1,683 @@ +"""Sharing a running Agent App beyond this machine. + +Apps only ever bind loopback. Sharing one means opening a CHANNEL: a +transport that relays visitors to 127.0.0.1:, plus a GRANT the app's +guards read (a2app_proxy.guard_request, _a2app_lib.js authorizeCaller): + + lan private — an in-process relay on this machine's LAN address. + Reachable by devices on the same network, only while switched on. + tunnel public — a cloudflared quick tunnel (https://*.trycloudflare.com). + +Both are shared the same way: by LINK. A grant is two files in the project +dir, `.-origin` (the origin browsers use through the channel) and +`.-secret` (what `?a2app_share=` trades for a session). Every relay +stamps what it forwards, so the guards treat all of it as remote: no +credential, no access, reads included. Closing a channel deletes its grant, +which ends every session opened through it at once. + +The grant files are also the channel's state of record: `link()` is derived +from them, so there is nothing to keep in sync on the project object. +""" + +import asyncio +import os +import re +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +from abc import ABC, abstractmethod +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Dict, Optional, Tuple + +try: + from loguru import logger +except ImportError: # pragma: no cover + import logging + + logger = logging.getLogger(__name__) + +from app.agent_app.a2app_proxy import HOP_HEADERS, SHARE_CHANNELS, SHARE_PARAM + +# Host-local, channel-lifetime state: never exported, never trusted on import. +SHARE_STATE_FILES = tuple( + f".{name}-{kind}" for name in SHARE_CHANNELS for kind in ("origin", "secret") +) + + +class ShareError(RuntimeError): + """A channel could not be opened; the message is for the owner.""" + + +def _read(path: Path) -> str: + try: + return path.read_text(encoding="utf-8").strip() + except Exception: + return "" + + +class ShareGrant: + """One channel's grant files — the only thing the app's guards see.""" + + def __init__(self, name: str): + self.name = name + + def _origin_file(self, project_dir: Path) -> Path: + return Path(project_dir) / f".{self.name}-origin" + + def _secret_file(self, project_dir: Path) -> Path: + return Path(project_dir) / f".{self.name}-secret" + + def origin(self, project_dir: Path) -> str: + return _read(self._origin_file(project_dir)) + + def publish(self, project_dir: Path, origin: str) -> None: + """Trust `origin` and mint the link secret. An existing secret is + kept, so re-publishing the same channel keeps links already sent.""" + self._origin_file(project_dir).write_text( + origin.rstrip("/") + "\n", encoding="utf-8" + ) + secret_file = self._secret_file(project_dir) + if not _read(secret_file): + secret_file.write_text(secrets.token_urlsafe(32), encoding="utf-8") + try: + os.chmod(secret_file, 0o600) + except Exception: + pass + + def revoke(self, project_dir: Path) -> None: + """Idempotent: closing runs often.""" + for path in (self._origin_file(project_dir), self._secret_file(project_dir)): + try: + path.unlink(missing_ok=True) + except Exception as e: + logger.warning(f"[AGENT_APP:SHARE] Could not remove {path.name}: {e}") + + def link(self, project_dir: Path) -> Optional[str]: + """The link to hand out: origin + secret. The bare origin admits nobody.""" + origin = self.origin(project_dir) + secret = _read(self._secret_file(project_dir)) + if not (origin and secret): + return None + return f"{origin}/?{SHARE_PARAM}={secret}" + + +class ShareChannel(ABC): + """A transport + its grant. Subclasses supply only the transport.""" + + name: str + + def __init__(self) -> None: + self.grant = ShareGrant(self.name) + + async def open(self, project: Any, port: int) -> str: + """Open (or re-open) the channel and return its share link.""" + project_dir = Path(project.path) + # No agent token = no credential the guards can check. They fail + # closed remotely anyway, but refuse here so the owner is told why + # instead of handed a link that only ever answers 503. Checked before + # touching anything, so a refused open leaves an existing share as is. + if not _read(project_dir / ".agent-token"): + raise ShareError( + "This app has no access token, so it can't be shared safely. " + "Restart the app and try again." + ) + await self.close(project) + origin = await self._connect(project, port) + self.grant.publish(project_dir, origin) + logger.info(f"[AGENT_APP:SHARE] {self.name} open for {project.name}: {origin}") + return self.grant.link(project_dir) # type: ignore[return-value] + + async def close(self, project: Any) -> None: + await self._disconnect(project) + self.grant.revoke(Path(project.path)) + + def link(self, project: Any) -> Optional[str]: + return self.grant.link(Path(project.path)) + + def restore(self, project: Any) -> None: + """At CraftBot start: most transports did not survive the restart, + so a leftover grant is revoked. Override when one can.""" + self.grant.revoke(Path(project.path)) + + @abstractmethod + async def _connect(self, project: Any, port: int) -> str: + """Bring the transport up for 127.0.0.1:`port`; return the origin + visitors will use. Raise ShareError with an owner-facing reason.""" + + @abstractmethod + async def _disconnect(self, project: Any) -> None: + """Tear the transport down. Must be a no-op when it is not up.""" + + +# ── private: the LAN relay ───────────────────────────────────────────────── + + +class LanRelay: + """HTTP + WebSocket relay from : to 127.0.0.1:. + + HTTP-aware on purpose: it stamps X-Forwarded-For on everything it + forwards, overwriting whatever the visitor sent, so the app's guard sees + every LAN request as remote. A raw TCP relay could not, and a LAN caller + sending `Host: 127.0.0.1` would pass for local. + + Runs in its own thread and event loop (a SelectorEventLoop, as the + external-app proxy does on Windows), so relaying never competes with + CraftBot's own loop. + """ + + def __init__(self, host: str, port: int, upstream_port: int): + self.host = host + self.port = port + self.upstream = f"http://127.0.0.1:{upstream_port}" + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._runner = None + self._session = None + + async def start(self) -> int: + """Listen, preferring the app's own port; returns the bound port.""" + self._loop = asyncio.SelectorEventLoop() + ready = threading.Event() + result: list = [None] + + def _run() -> None: + asyncio.set_event_loop(self._loop) + try: + result[0] = self._loop.run_until_complete(self._setup()) + except Exception as e: + result[0] = e + ready.set() + if not isinstance(result[0], Exception): + self._loop.run_forever() + + threading.Thread(target=_run, daemon=True, name=f"lan-relay-{self.port}").start() + await asyncio.get_running_loop().run_in_executor(None, ready.wait, 10) + if not isinstance(result[0], int): + self._loop = None + raise ShareError(f"Could not listen on {self.host}: {result[0]}") + return result[0] + + async def _setup(self) -> int: + import aiohttp + from aiohttp import web + + self._session = aiohttp.ClientSession( + auto_decompress=False, + timeout=aiohttp.ClientTimeout(total=None, sock_connect=10), + ) + app = web.Application(client_max_size=1024**3) + app.router.add_route("*", "/{tail:.*}", self._handle) + self._runner = web.AppRunner(app, access_log=None) + await self._runner.setup() + # The app's own port when free on this address (a stable URL); + # otherwise any port — the link carries it either way. + for port in (self.port, 0): + site = web.TCPSite(self._runner, self.host, port) + try: + await site.start() + except OSError: + if port == 0: + raise + continue + return site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + raise OSError("unreachable") + + async def stop(self) -> None: + loop, self._loop = self._loop, None + if loop is None: + return + + async def _cleanup() -> None: + if self._runner is not None: + await self._runner.cleanup() + if self._session is not None: + await self._session.close() + + try: + fut = asyncio.run_coroutine_threadsafe(_cleanup(), loop) + await asyncio.wrap_future(fut) + except Exception: + pass + loop.call_soon_threadsafe(loop.stop) + + def _forward_headers(self, request, drop=()) -> Dict[str, str]: + headers = { + k: v + for k, v in request.headers.items() + if k.lower() not in HOP_HEADERS and k.lower() not in drop + } + # The remote marker (see a2app_proxy.REMOTE_MARKER_HEADERS): set, never + # appended — nothing the visitor sends survives. + headers["X-Forwarded-For"] = request.remote or "unknown" + headers["X-Forwarded-Proto"] = "http" + headers["X-Forwarded-Host"] = request.host + return headers + + async def _handle(self, request): + from aiohttp import web + + if request.headers.get("Upgrade", "").lower() == "websocket": + return await self._relay_ws(request) + try: + async with self._session.request( + request.method, + self.upstream + str(request.rel_url), + headers=self._forward_headers(request), + data=request.content if request.body_exists else None, + allow_redirects=False, + ) as up: + resp = web.StreamResponse(status=up.status) + for k, v in up.headers.items(): + if k.lower() not in HOP_HEADERS: + resp.headers.add(k, v) # add: several Set-Cookie + await resp.prepare(request) + async for chunk in up.content.iter_chunked(64 * 1024): + await resp.write(chunk) + await resp.write_eof() + return resp + except (ConnectionResetError, ConnectionAbortedError): + raise + except Exception: + return web.Response(status=502, text="The app is not running.") + + async def _relay_ws(self, request): + import aiohttp + from aiohttp import web + + protocols = tuple( + p.strip() + for p in request.headers.get("Sec-WebSocket-Protocol", "").split(",") + if p.strip() + ) + headers = self._forward_headers( + request, + drop=( + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-protocol", + ), + ) + # Connect upstream FIRST: if the app's guard refuses the handshake, + # the visitor gets that refusal, not an open socket that goes nowhere. + try: + client_ws = await self._session.ws_connect( + self.upstream + str(request.rel_url), + headers=headers, + protocols=protocols, + ) + except aiohttp.WSServerHandshakeError as e: + return web.Response(status=e.status or 502) + except Exception: + return web.Response(status=502, text="The app is not running.") + server_ws = web.WebSocketResponse(protocols=protocols) + await server_ws.prepare(request) + + async def pump(src, dst): + async for msg in src: + if msg.type == aiohttp.WSMsgType.TEXT: + await dst.send_str(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await dst.send_bytes(msg.data) + else: + break + + try: + await asyncio.wait( + [ + asyncio.ensure_future(pump(server_ws, client_ws)), + asyncio.ensure_future(pump(client_ws, server_ws)), + ], + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + await client_ws.close() + await server_ws.close() + return server_ws + + +class LanChannel(ShareChannel): + """Private link: devices on the same network, via LanRelay.""" + + name = "lan" + + def __init__(self) -> None: + super().__init__() + self._relays: Dict[str, LanRelay] = {} + + @staticmethod + def lan_ip() -> Optional[str]: + """This machine's address on the network its default route uses.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(1) + s.connect(("8.8.8.8", 80)) # no packet is sent; picks the interface + ip = s.getsockname()[0] + s.close() + except Exception: + try: + ip = socket.gethostbyname(socket.gethostname()) + except Exception: + return None + return None if ip.startswith("127.") else ip + + async def _connect(self, project: Any, port: int) -> str: + ip = self.lan_ip() + if not ip: + raise ShareError("This computer isn't connected to a local network.") + relay = LanRelay(ip, port, port) + bound = await relay.start() + self._relays[project.id] = relay + return f"http://{ip}:{bound}" + + async def _disconnect(self, project: Any) -> None: + relay = self._relays.pop(project.id, None) + if relay is not None: + await relay.stop() + + +# ── public: the cloudflared tunnel ───────────────────────────────────────── + + +class TunnelChannel(ShareChannel): + """Public link: a cloudflared quick tunnel, one process per project.""" + + name = "tunnel" + + _CLOUDFLARED_URLS = { + "win32": "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe", + "darwin": "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz", + "linux": "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64", + } + _BIN_DIR = Path(__file__).parent.parent / "bin" + + def __init__(self, terminate: Callable[[subprocess.Popen], None]): + super().__init__() + self._terminate = terminate + # project id -> (cloudflared process, its open log file) + self._running: Dict[str, Tuple[subprocess.Popen, Any]] = {} + + # ── transport ── + + async def _connect(self, project: Any, port: int) -> str: + if not self._running: + await self._kill_orphans() + + cloudflared = await self._ensure_cloudflared() + if not cloudflared: + raise ShareError("Couldn't install cloudflared, which public links need.") + + # cloudflared writes to stderr for the WHOLE life of the tunnel, not + # just at startup. Piping that into this process and then not draining + # it — which is what "find the URL, return from the reader thread" + # did — fills the OS pipe buffer (4 KB by default on Windows) and + # cloudflared then BLOCKS forever on its next write. The tunnel stops + # proxying while the process still looks perfectly alive, so remote + # visitors hang until their client times out, and every byte that would + # explain why is stuck unread in that buffer. A file sink has no such + # backpressure, and doubles as the log this had no way to produce. + log_handle, log_path, log_offset = self._open_log(project, port) + if log_handle is None: + raise ShareError("No writable location for the cloudflared log.") + + # 127.0.0.1, NOT localhost: PocketBase binds --http=127.0.0.1: + # (runner.start) and the external-app proxy binds the same, so neither + # ever listens on ::1. cloudflared resolves 'localhost' to ::1 first on + # Windows and got "connectex: No connection could be made" on every + # single request — the tunnel came up healthy, announced its URL, and + # then refused every visitor. + origin_url = f"http://127.0.0.1:{port}" + logger.info( + f"[AGENT_APP:SHARE] Starting cloudflared: {cloudflared} tunnel " + f"--url {origin_url} (log: {log_path})" + ) + proc = subprocess.Popen( + [cloudflared, "tunnel", "--url", origin_url], + stdout=log_handle, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW + if os.name == "nt" and hasattr(subprocess, "CREATE_NO_WINDOW") + else 0, + ) + url = await self._parse_url(proc, log_path, log_offset) + if not url: + self._terminate(proc) + self._close_log(log_handle) + raise ShareError( + f"cloudflared didn't come up; its own output is in {log_path}" + ) + self._running[project.id] = (proc, log_handle) + return url + + async def _disconnect(self, project: Any) -> None: + proc, log_handle = self._running.pop(project.id, (None, None)) + if proc is not None: + self._terminate(proc) + self._close_log(log_handle) + + def restore(self, project: Any) -> None: + """cloudflared outlives CraftBot: keep a grant whose tunnel still + answers (401 is the app refusing a visitor with no session — the + tunnel is up), revoke one that does not.""" + origin = self.grant.origin(Path(project.path)) + if not origin: + return + import urllib.error + import urllib.request + + try: + try: + urllib.request.urlopen( + urllib.request.Request(origin, method="HEAD"), timeout=3 + ) + except urllib.error.HTTPError as he: + if he.code != 401: + raise + logger.info(f"[AGENT_APP:SHARE] Tunnel still active for {project.name}") + except Exception: + logger.info(f"[AGENT_APP:SHARE] Tunnel expired for {project.name}") + self.grant.revoke(Path(project.path)) + + async def _kill_orphans(self) -> None: + """Only when no tunnel of ours is running: a cloudflared left over + from a previous CraftBot would otherwise pile up.""" + try: + if os.name == "nt": + subprocess.run( + [ + "powershell", + "-Command", + "Stop-Process -Name cloudflared -Force -ErrorAction SilentlyContinue", + ], + capture_output=True, + timeout=5, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + else: + subprocess.run(["pkill", "-f", "cloudflared"], capture_output=True) + await asyncio.sleep(1) + except Exception: + pass + + # ── cloudflared binary ── + + def _cloudflared_path(self) -> Optional[str]: + """PATH first, then our local bin directory.""" + system_path = shutil.which("cloudflared") + if system_path: + return system_path + ext = ".exe" if sys.platform == "win32" else "" + local_bin = self._BIN_DIR / f"cloudflared{ext}" + return str(local_bin) if local_bin.exists() else None + + async def _ensure_cloudflared(self) -> Optional[str]: + path = self._cloudflared_path() + if path: + return path + logger.info("[AGENT_APP:SHARE] cloudflared not found, auto-installing...") + import urllib.request + + platform_key = sys.platform + if platform_key not in self._CLOUDFLARED_URLS: + logger.error(f"[AGENT_APP:SHARE] Unsupported platform: {platform_key}") + return None + self._BIN_DIR.mkdir(parents=True, exist_ok=True) + ext = ".exe" if platform_key == "win32" else "" + target = self._BIN_DIR / f"cloudflared{ext}" + try: + req = urllib.request.Request( + self._CLOUDFLARED_URLS[platform_key], headers={"User-Agent": "CraftBot"} + ) + resp = urllib.request.urlopen(req, timeout=60) + if platform_key == "darwin": + import io + import tarfile + + with tarfile.open(fileobj=io.BytesIO(resp.read()), mode="r:gz") as tar: + for member in tar.getmembers(): + if "cloudflared" in member.name: + f = tar.extractfile(member) + if f: + target.write_bytes(f.read()) + break + else: + target.write_bytes(resp.read()) + if platform_key != "win32": + target.chmod(0o755) + logger.info(f"[AGENT_APP:SHARE] cloudflared installed at {target}") + return str(target) + except Exception as e: + logger.error(f"[AGENT_APP:SHARE] Failed to download cloudflared: {e}") + if target.exists(): + target.unlink() + return None + + # ── cloudflared output ── + + @staticmethod + def log_path(project: Any) -> Path: + return Path(project.path) / "logs" / "cloudflared.log" + + def _open_log(self, project: Any, port: int) -> Tuple[Optional[Any], Path, int]: + """Open cloudflared's output sink. Returns (handle, path, offset). + + The sink is not optional — it is both the tunnel's only log and the + only place the public URL is announced — so an unwritable project + directory falls back to the temp dir rather than failing the share. + """ + candidates = [ + self.log_path(project), + Path(tempfile.gettempdir()) / f"cloudflared-{project.id}.log", + ] + for path in candidates: + try: + path.parent.mkdir(parents=True, exist_ok=True) + # Append across restarts, but never grow without bound: this + # file collects everything cloudflared logs while sharing. + too_big = path.exists() and path.stat().st_size > 2_000_000 + handle = open( + path, "w" if too_big else "a", encoding="utf-8", errors="replace" + ) + handle.write( + f"\n=== cloudflared start " + f"{datetime.now().isoformat(timespec='seconds')} " + f"port={port} ===\n" + ) + handle.flush() + return handle, path, path.stat().st_size + except Exception as e: + logger.warning(f"[AGENT_APP:SHARE] Tunnel log unusable at {path}: {e}") + return None, candidates[-1], 0 + + @staticmethod + def _close_log(handle: Optional[Any]) -> None: + if handle is None: + return + try: + handle.close() + except Exception: + pass + + @staticmethod + async def _parse_url( + proc: subprocess.Popen, log_path: Path, start_offset: int = 0, timeout: int = 30 + ) -> Optional[str]: + """Wait for cloudflared to announce its public URL in its log file. + Tails the file rather than reading the process pipes — see the note + in _connect about the pipe-buffer deadlock that cost us the tunnel.""" + pattern = re.compile(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com") + deadline = time.time() + timeout + offset = start_offset + seen = "" + while True: + # Sample liveness BEFORE reading, so a process that dies between + # the two still gets its final bytes examined. + exited = proc.poll() is not None + try: + with open(log_path, "r", encoding="utf-8", errors="replace") as fh: + fh.seek(offset) + seen += fh.read() + offset = fh.tell() + except FileNotFoundError: + pass + match = pattern.search(seen) + if match: + return match.group(0) + # cloudflared boxes the URL inside an ASCII banner, so it can land + # split across two reads: keep a tail long enough to re-match. + if len(seen) > 8192: + seen = seen[-1024:] + if exited: + logger.error( + f"[AGENT_APP:SHARE] cloudflared exited (code {proc.returncode}) " + f"before announcing a URL; see {log_path}" + ) + return None + if time.time() >= deadline: + logger.error( + f"[AGENT_APP:SHARE] No cloudflare URL within {timeout}s; see {log_path}" + ) + return None + await asyncio.sleep(0.3) + + +# ── the service the manager composes ────────────────────────────────────── + + +class SharingService: + """Every share channel, behind one interface. The manager decides WHEN + (project running, which port); the channels decide HOW.""" + + def __init__(self, terminate: Callable[[subprocess.Popen], None]): + self.channels: Dict[str, ShareChannel] = { + c.name: c for c in (LanChannel(), TunnelChannel(terminate)) + } + assert set(self.channels) == set(SHARE_CHANNELS), "guards must know every channel" + + def channel(self, name: str) -> ShareChannel: + try: + return self.channels[name] + except KeyError: + raise ShareError(f"Unknown share channel: {name}") from None + + async def open(self, project: Any, name: str, port: int) -> str: + return await self.channel(name).open(project, port) + + async def close(self, project: Any, name: str) -> None: + await self.channel(name).close(project) + + async def close_all(self, project: Any) -> None: + for channel in self.channels.values(): + await channel.close(project) + + def links(self, project: Any) -> Dict[str, Optional[str]]: + return {name: c.link(project) for name, c in self.channels.items()} + + def restore(self, project: Any) -> None: + for channel in self.channels.values(): + channel.restore(project) diff --git a/app/agent_app/test_a2app_external.py b/app/agent_app/test_a2app_external.py index 210155ea..c86ee273 100644 --- a/app/agent_app/test_a2app_external.py +++ b/app/agent_app/test_a2app_external.py @@ -32,6 +32,7 @@ PROXY_PORT = 18471 UPSTREAM_PORT = 18472 +LAN_PORT = 18473 TOKEN = "test-agent-token" @@ -419,6 +420,78 @@ async def _no_token_matrix(http, base: str, tmp: Path) -> None: token_file.write_text(TOKEN, encoding="utf-8") +async def _lan_matrix(http, tmp: Path, seen) -> None: + """The private LAN link, end to end: a real LanRelay in front of the + proxy. The relay stamps X-Forwarded-For on everything, so the guard sees + LAN visitors as remote — no link, no access — whatever they send.""" + from app.agent_app.sharing import LanRelay, ShareGrant + + relay = LanRelay("127.0.0.1", LAN_PORT, PROXY_PORT) + bound = await relay.start() + assert bound == LAN_PORT, bound + base = f"http://127.0.0.1:{bound}" + create = f"{base}/api/ops/todos/create" + lan_origin = "http://192.168.1.50:3101" + before = len(seen["todos"]) + grant = ShareGrant("lan") + try: + async def post(headers, cookie=None, title="x"): + h = dict(headers) + if cookie: + h["Cookie"] = f"{cookie[0]}={cookie[1]}" + async with http.post(create, json={"title": title}, headers=h) as r: + return r.status + + # LAN link switched off: nothing gets through, however it asks — + # including a visitor claiming to be local (the relay overwrites it). + for headers in ({}, {"Host": f"127.0.0.1:{PROXY_PORT}"}, {"X-Forwarded-For": "127.0.0.1"}): + async with http.get(f"{base}/api/_a2app", headers=headers) as r: + assert r.status == 401, (headers, r.status) + assert (await r.json())["code"] == "share_session_required" + async with http.get(f"{base}/") as r: + assert "Set-Cookie" not in r.headers, "no local session over the LAN" + async with http.get(f"{base}/?a2app_share=anything", allow_redirects=False) as r: + assert r.status == 403 + + # switched on: the link's secret buys a (non-Secure: plain http) session + grant.publish(tmp, lan_origin) + secret = (tmp / ".lan-secret").read_text(encoding="utf-8").strip() + async with http.get( + f"{base}/?a2app_share={secret}&tab=2", allow_redirects=False + ) as r: + assert r.status == 302 and r.headers["Location"] == "/?tab=2" + lan = _set_cookie(r) + assert "Secure" not in r.headers["Set-Cookie"], "http LAN needs a plain cookie" + async with http.get( + f"{base}/api/_a2app", headers={"Cookie": f"{lan[0]}={lan[1]}"} + ) as r: + assert r.status == 200 + assert await post({"Origin": lan_origin}, cookie=lan, title="lan visitor") == 200 + assert await post({"Origin": lan_origin}) == 401, "the LAN origin authenticates nobody" + assert await post({"Origin": "https://evil.example"}, cookie=lan) == 403 + assert await post({"X-A2App-Token": TOKEN}, title="lan agent") == 200 + + # a local session is never a LAN credential, nor the reverse + async with http.get(f"http://127.0.0.1:{PROXY_PORT}/") as r: + local = _set_cookie(r) + assert await post({"Origin": lan_origin}, cookie=local) == 401 + async with http.post( + f"http://127.0.0.1:{PROXY_PORT}/api/ops/todos/create", + json={"title": "x"}, + headers={"Cookie": f"{lan[0]}={lan[1]}"}, + ) as r: + assert r.status == 401, "a LAN session is not a local credential" + + # switched off: every LAN session ends at once + grant.revoke(tmp) + assert await post({"Origin": lan_origin}, cookie=lan) in (401, 403) + titles = [t["title"] for t in seen["todos"][before:]] + assert titles == ["lan visitor", "lan agent"], titles + finally: + grant.revoke(tmp) + await relay.stop() + + async def _proxy_suite(tmp: Path) -> None: import aiohttp @@ -493,6 +566,7 @@ async def _proxy_suite(tmp: Path) -> None: await _auth_matrix(http, base, tmp, seen) await _no_token_matrix(http, base, tmp) + await _lan_matrix(http, tmp, seen) # unknown op -> 404 envelope, never a silent passthrough async with http.post(f"{base}/api/ops/nope", json={}, headers=auth) as r: diff --git a/app/agent_app/test_a2app_native_auth.py b/app/agent_app/test_a2app_native_auth.py index 6ac390da..371026d8 100644 --- a/app/agent_app/test_a2app_native_auth.py +++ b/app/agent_app/test_a2app_native_auth.py @@ -220,6 +220,64 @@ def count(): token_file.write_text(TOKEN, encoding="utf-8") +def _lan_suite(base: str, proj: Path) -> None: + """The private LAN link, through a real LanRelay in front of PocketBase: + the relay's X-Forwarded-For stamp puts every LAN visitor under remote + rules, and `.lan-origin`/`.lan-secret` are honoured exactly like the + tunnel's (origin guard, CORS, /api/_console, share exchange).""" + import asyncio + + from app.agent_app.sharing import LanRelay, ShareGrant + + port = int(base.rsplit(":", 1)[1]) + relay = LanRelay("127.0.0.1", 0, port) + lan = f"http://127.0.0.1:{asyncio.run(relay.start())}" + lan_origin = "http://192.168.1.50:3101" + create = "/api/collections/items/records" + grant = ShareGrant("lan") + try: + # switched off: nothing, however the visitor dresses up as local + for headers in ({}, {"Host": "127.0.0.1"}, {"X-Forwarded-For": "127.0.0.1"}): + status, _, body = _req(lan, "GET", create, headers) + assert status == 401 and body["code"] == "share_session_required", headers + assert _req(lan, "GET", "/")[0] == 401, "the UI itself is gated" + assert _req(lan, "GET", "/?a2app_share=anything")[0] == 403 + + # switched on + grant.publish(proj, lan_origin) + secret = (proj / ".lan-secret").read_text(encoding="utf-8").strip() + status, headers, _ = _req(lan, "GET", f"/?a2app_share={secret}&tab=2") + assert status == 302 and headers["Location"] == "/?tab=2", status + session = _session_cookie(headers) + assert session and "Secure" not in session[2], "http LAN needs a plain cookie" + session = session[:2] + + assert _req(lan, "GET", "/", cookie=session)[0] == 200 + status, headers, _ = _req(lan, "GET", create, {"Origin": lan_origin}, cookie=session) + assert status == 200 + assert headers["Access-Control-Allow-Origin"] == lan_origin, "CORS grant for the LAN origin" + origin = {"Origin": lan_origin} + assert _req(lan, "POST", create, origin, {"title": "lan"}, session)[0] == 200 + assert _req(lan, "POST", create, origin, {"title": "x"})[0] == 401, "origin is no credential" + assert _req( + lan, "POST", create, {"Origin": "https://evil.example"}, {"title": "x"}, session + )[0] == 403 + assert _req( + lan, "POST", "/api/_console", origin, {"entries": []}, session + )[0] == 200, "console relay accepts the LAN origin" + assert _req(base, "POST", create, {"Origin": base}, {"title": "x"}, session)[0] == 401, ( + "a LAN session is not a local credential" + ) + + # switched off: every LAN session ends at once + grant.revoke(proj) + assert _req(lan, "GET", create, cookie=session)[0] == 401 + assert _req(lan, "POST", "/api/_console", origin, {"entries": []}, session)[0] in (401, 403) + finally: + grant.revoke(proj) + asyncio.run(relay.stop()) + + def main() -> None: pb = _pinned_pb_binary() if not pb.exists(): @@ -280,6 +338,7 @@ def main() -> None: else: raise AssertionError("PocketBase never became healthy") _suite(base, proj, superuser) + _lan_suite(base, proj) finally: proc.terminate() try: diff --git a/app/agent_app/test_sharing.py b/app/agent_app/test_sharing.py new file mode 100644 index 00000000..e96dc897 --- /dev/null +++ b/app/agent_app/test_sharing.py @@ -0,0 +1,169 @@ +"""Share channels: the LAN relay's transport and the channel lifecycle. + +The guard side of LAN sharing (who gets in) is pinned end to end in +test_a2app_external.py (_lan_matrix) and test_a2app_native_auth.py +(_lan_suite). This file pins what those do not reach: the relay forwards +faithfully (WebSockets, repeated Set-Cookie) while stamping every request as +remote, and a channel's open/close leaves exactly one grant behind or none. + +Run: python -m app.agent_app.test_sharing + +Style follows test_data_safety.py: a module-level assert script, no pytest. +""" + +import asyncio +import sys +import tempfile +from pathlib import Path + +try: + sys.stdout.reconfigure(encoding="utf-8") +except Exception: + pass + +from app.agent_app.sharing import LanChannel, LanRelay, ShareError, SharingService + +UPSTREAM_PORT = 18481 +RELAY_PORT = 18482 + + +async def _start_upstream(): + from aiohttp import web + + seen = {} + + async def echo(request): + seen["headers"] = dict(request.headers) + resp = web.json_response({"ok": True}) + resp.headers.add("Set-Cookie", "a=1; Path=/") + resp.headers.add("Set-Cookie", "b=2; Path=/") + return resp + + async def ws(request): + seen["ws_headers"] = dict(request.headers) + sock = web.WebSocketResponse() + await sock.prepare(request) + async for msg in sock: + await sock.send_str("echo:" + msg.data) + return sock + + app = web.Application() + app.router.add_get("/echo", echo) + app.router.add_get("/ws", ws) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + await web.TCPSite(runner, "127.0.0.1", UPSTREAM_PORT).start() + return runner, seen + + +async def _check_relay() -> None: + import aiohttp + + upstream, seen = await _start_upstream() + relay = LanRelay("127.0.0.1", RELAY_PORT, UPSTREAM_PORT) + port = await relay.start() + base = f"http://127.0.0.1:{port}" + try: + async with aiohttp.ClientSession() as http: + # The stamp is SET, never appended: nothing a visitor sends survives. + async with http.get( + f"{base}/echo", + headers={"X-Forwarded-For": "127.0.0.1", "Host": "127.0.0.1"}, + ) as r: + assert r.status == 200 + cookies = r.headers.getall("Set-Cookie") + assert len(cookies) == 2, f"every Set-Cookie must survive: {cookies}" + assert seen["headers"]["X-Forwarded-For"] == "127.0.0.1" # the real peer + assert seen["headers"]["X-Forwarded-Proto"] == "http" + + # A second relay on a taken port falls back to any free one. + clash = LanRelay("127.0.0.1", port, UPSTREAM_PORT) + other = await clash.start() + assert other != port, "a taken port must not fail the share" + await clash.stop() + + # WebSockets: relayed both ways, handshake stamped like HTTP. + async with http.ws_connect(f"{base}/ws") as sock: + await sock.send_str("hi") + msg = await sock.receive(timeout=5) + assert msg.data == "echo:hi", msg + assert "X-Forwarded-For" in seen["ws_headers"] + + await relay.stop() + await relay.stop() # idempotent + async with aiohttp.ClientSession() as http: + try: + await http.get(f"{base}/echo", timeout=aiohttp.ClientTimeout(total=3)) + except aiohttp.ClientError: + pass + else: + raise AssertionError("a stopped relay must stop listening") + finally: + await relay.stop() + await upstream.cleanup() + + +print_relay = "§1 LAN relay forwards faithfully and stamps every request: OK" + + +class _Project: + def __init__(self, path: Path): + self.id, self.name, self.path = "p1", "T", str(path) + + +async def _check_lifecycle(tmp: Path) -> None: + project = _Project(tmp) + sharing = SharingService(terminate=lambda proc: proc.kill()) + assert sharing.links(project) == {"lan": None, "tunnel": None} + + try: + await sharing.open(project, "lan", UPSTREAM_PORT) + except ShareError as e: + assert "access token" in str(e) + else: + raise AssertionError("shared without an agent token") + + try: + await sharing.open(project, "bogus", UPSTREAM_PORT) + except ShareError: + pass + else: + raise AssertionError("unknown channel accepted") + + (tmp / ".agent-token").write_text("tok", encoding="utf-8") + lan: LanChannel = sharing.channels["lan"] # type: ignore[assignment] + lan.lan_ip = staticmethod(lambda: "127.0.0.1") # no real network in tests + link = await sharing.open(project, "lan", UPSTREAM_PORT) + secret = (tmp / ".lan-secret").read_text(encoding="utf-8").strip() + # The relay prefers the app's own port (free here: no upstream running). + assert link == f"http://127.0.0.1:{UPSTREAM_PORT}/?a2app_share={secret}", link + assert sharing.links(project) == {"lan": link, "tunnel": None} + + # Re-opening is a fresh share: the old link must stop working. + again = await sharing.open(project, "lan", UPSTREAM_PORT) + assert again != link, "re-open must mint a new secret" + assert len(lan._relays) == 1, "re-open must not leak the old relay" + + await sharing.close_all(project) + assert sharing.links(project) == {"lan": None, "tunnel": None} + assert not (tmp / ".lan-origin").exists() and not (tmp / ".lan-secret").exists() + assert not lan._relays + + # A LAN grant left behind by a previous CraftBot has no relay: revoked. + (tmp / ".lan-origin").write_text("http://192.168.1.5:3101", encoding="utf-8") + (tmp / ".lan-secret").write_text("stale", encoding="utf-8") + lan.restore(project) + assert not (tmp / ".lan-origin").exists() and not (tmp / ".lan-secret").exists() + + +print_lifecycle = "§2 channels open, re-open and close to exactly one grant or none: OK" + + +asyncio.run(_check_relay()) +print(print_relay) + +with tempfile.TemporaryDirectory() as _tmp: + asyncio.run(_check_lifecycle(Path(_tmp))) + print(print_lifecycle) + +print("sharing: all checks OK") diff --git a/app/agent_app/test_tunnel.py b/app/agent_app/test_tunnel.py index 6a64ed58..e5892225 100644 --- a/app/agent_app/test_tunnel.py +++ b/app/agent_app/test_tunnel.py @@ -1,4 +1,4 @@ -"""Tunnel sharing: the output sink and the shared-origin grant. +"""Public sharing (TunnelChannel): the output sink and the shared-origin grant. Four failures this pins down, all observed live on 2026-08-28. Each one alone was enough to make a shared app unusable, and the first hid the other three: @@ -44,6 +44,7 @@ from app.agent_app.a2app_proxy import ExternalA2AppProxy from app.agent_app.manager import AgentAppManager, AgentAppProject +from app.agent_app.sharing import ShareError, ShareGrant, TunnelChannel # A stand-in for cloudflared: the real banner shape, then far more chatter # than any pipe buffer holds. This is the exact shape that used to wedge. @@ -62,27 +63,26 @@ URL = "https://fake-tunnel-for-tests.trycloudflare.com" -def _fixture(tmp: Path) -> "tuple[AgentAppManager, AgentAppProject, Path]": - """A manager with no state of its own — these paths never touch it.""" +def _fixture(tmp: Path) -> "tuple[TunnelChannel, AgentAppProject, Path]": (tmp / "logs").mkdir(exist_ok=True) fake = tmp / "fake_cloudflared.py" fake.write_text(FAKE_CLOUDFLARED, encoding="utf-8") - mgr = AgentAppManager.__new__(AgentAppManager) + channel = TunnelChannel(terminate=lambda proc: proc.kill()) project = AgentAppProject(id="testproj", name="T", description="", path=str(tmp)) - return mgr, project, fake + return channel, project, fake async def _check_sink(tmp: Path) -> None: - mgr, project, fake = _fixture(tmp) + channel, project, fake = _fixture(tmp) - handle, log_path, offset = mgr._open_tunnel_log(project, 3101) + handle, log_path, offset = channel._open_log(project, 3101) assert handle is not None, "no sink means no URL and no log" assert log_path == tmp / "logs" / "cloudflared.log", log_path proc = subprocess.Popen( [sys.executable, str(fake)], stdout=handle, stderr=subprocess.STDOUT ) - url = await mgr._parse_cloudflare_url(proc, log_path, offset, timeout=20) + url = await channel._parse_url(proc, log_path, offset, timeout=20) assert url == URL, url # THE regression: the child must run to completion, not block on output. @@ -91,7 +91,7 @@ async def _check_sink(tmp: Path) -> None: except subprocess.TimeoutExpired: proc.kill() raise AssertionError("cloudflared blocked on its output — deadlock is back") - mgr._close_tunnel_log(handle) + channel._close_log(handle) body = log_path.read_text(encoding="utf-8", errors="replace") assert "=== cloudflared start" in body, "session header missing" @@ -103,20 +103,20 @@ async def _check_sink(tmp: Path) -> None: async def _check_failure_paths(tmp: Path) -> None: - mgr, project, _ = _fixture(tmp) + channel, project, _ = _fixture(tmp) # A cloudflared that dies without announcing must fail fast, not sit out # the whole timeout — the launch path is awaiting this. dead = subprocess.Popen([sys.executable, "-c", "raise SystemExit(3)"]) dead.wait() - url = await mgr._parse_cloudflare_url(dead, tmp / "absent.log", 0, timeout=30) + url = await channel._parse_url(dead, tmp / "absent.log", 0, timeout=30) assert url is None, url # The log is append-mode across restarts, but capped. - log_path = mgr._tunnel_log_path(project) + log_path = channel.log_path(project) log_path.write_text("y" * 2_500_000, encoding="utf-8") - handle, _, offset = mgr._open_tunnel_log(project, 3101) - mgr._close_tunnel_log(handle) + handle, _, offset = channel._open_log(project, 3101) + channel._close_log(handle) assert offset < 1000, "an oversized log must be rotated, not grown (%d)" % offset @@ -124,13 +124,13 @@ async def _check_failure_paths(tmp: Path) -> None: def _check_origin_grant(tmp: Path) -> None: - mgr, project, _ = _fixture(tmp) + grant = ShareGrant("tunnel") origin_file = tmp / ".tunnel-origin" # The guard reads this file per request, so publishing it is the whole # grant — no app restart, and the trailing slash must not survive or the # string comparison against the browser's Origin header fails. - mgr._publish_tunnel_origin(project, URL + "/") + grant.publish(tmp, URL + "/") assert origin_file.read_text(encoding="utf-8").strip() == URL, "bad origin file" # The origin grant authenticates nobody; the share secret does. It is @@ -139,19 +139,18 @@ def _check_origin_grant(tmp: Path) -> None: secret_file = tmp / ".tunnel-secret" secret = secret_file.read_text(encoding="utf-8").strip() assert len(secret) >= 32, "share secret must be minted with the grant" - mgr._publish_tunnel_origin(project, URL) + grant.publish(tmp, URL) assert secret_file.read_text(encoding="utf-8").strip() == secret - mgr.projects = {project.id: project} - project.tunnel_url = URL - assert mgr.get_tunnel_share_url(project.id) == f"{URL}/?a2app_share={secret}" + assert grant.link(tmp) == f"{URL}/?a2app_share={secret}" - mgr._publish_tunnel_origin(project, None) + grant.revoke(tmp) assert not origin_file.exists(), "stopping the tunnel must revoke the grant" assert not secret_file.exists(), "stopping the tunnel must end every session" - mgr._publish_tunnel_origin(project, None) # idempotent: stop_tunnel runs often + assert grant.link(tmp) is None, "no grant, no link" + grant.revoke(tmp) # idempotent: closing runs often # A new tunnel is a new secret: old links must not reopen it. - mgr._publish_tunnel_origin(project, URL) + grant.publish(tmp, URL) assert secret_file.read_text(encoding="utf-8").strip() != secret @@ -164,17 +163,14 @@ def _check_serving_port(tmp: Path) -> None: Live case: port=3100 (PocketBase listening, serving edits), backend_port= 3101 (allocated, bound by nothing). Sharing preferred backend_port, so the tunnel came up healthy and then answered every visitor with a refused - connection. + connection. Nothing binds backend_port any more, so there is no fallback. """ - mgr, project, _ = _fixture(tmp) + project = AgentAppProject(id="p", name="T", description="", path=str(tmp)) project.port, project.backend_port = 3100, 3101 - assert mgr._serving_port(project) == 3100, "must follow runner.start's port" + assert AgentAppManager._serving_port(project) == 3100, "must follow runner.start's port" project.port = None - assert mgr._serving_port(project) == 3101, "fall back, don't return None" - - project.backend_port = None - assert mgr._serving_port(project) is None + assert AgentAppManager._serving_port(project) is None, "never the unbound backend_port" print_port = "§5 sharing targets the bound port, not the reserved one: OK" @@ -205,16 +201,15 @@ def _check_external_guard(tmp: Path) -> None: async def _check_tunnel_needs_token(tmp: Path) -> None: """A failed token mint (launch only warns) + "share this app" used to be - a publicly writable app. start_tunnel refuses, before touching anything.""" - mgr, project, _ = _fixture(tmp) - project.status = "running" - mgr.projects = {project.id: project} - stopped = [] + a publicly writable app. Opening a channel refuses, before touching + anything — including a share that is already open.""" + channel, project, _ = _fixture(tmp) + closed = [] - async def _stop(pid): - stopped.append(pid) + async def _close(p): + closed.append(p.id) - mgr.stop_tunnel = _stop + channel.close = _close token_file = tmp / ".agent-token" for content in (None, "", " \n"): if content is None: @@ -222,12 +217,12 @@ async def _stop(pid): else: token_file.write_text(content, encoding="utf-8") try: - await mgr.start_tunnel(project.id) - except RuntimeError as e: + await channel.open(project, 3101) + except ShareError as e: assert "access token" in str(e), e else: raise AssertionError(f"shared without a token ({content!r})") - assert not stopped, "refused before touching any existing tunnel" + assert not closed, "refused before touching any existing share" assert not (tmp / ".tunnel-secret").exists() assert not (tmp / ".tunnel-origin").exists() diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index e7feaa91..8ecaf16e 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -162,6 +162,7 @@ def _with_request_id(message: Dict[str, Any]) -> Dict[str, Any]: register_broadcast_callbacks, make_todo_broadcast_hook, ) +from app.agent_app.sharing import ShareError if TYPE_CHECKING: from app.ui_layer.controller.ui_controller import UIController @@ -2108,14 +2109,15 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: elif msg_type == "agent_app_state_update": await self._handle_agent_app_state_update(data) - elif msg_type == "agent_app_tunnel_start": - project_id = data.get("projectId", "") - provider = data.get("provider", "cloudflared") - await self._handle_agent_app_tunnel_start(project_id, provider) + elif msg_type == "agent_app_share_open": + await self._handle_agent_app_share( + data.get("projectId", ""), data.get("channel", ""), open_it=True + ) - elif msg_type == "agent_app_tunnel_stop": - project_id = data.get("projectId", "") - await self._handle_agent_app_tunnel_stop(project_id) + elif msg_type == "agent_app_share_close": + await self._handle_agent_app_share( + data.get("projectId", ""), data.get("channel", ""), open_it=False + ) elif msg_type == "agent_app_sharing_info": project_id = data.get("projectId", "") @@ -3936,71 +3938,41 @@ async def _handle_agent_app_state_update(self, data: Dict[str, Any]) -> None: except Exception as e: logger.error(f"[AGENT_APP] Error handling state update: {e}") - async def _handle_agent_app_sharing_info(self, project_id: str) -> None: - """Return sharing info (LAN URL, tunnel URL).""" - lan_url = self._agent_app_manager.get_lan_url(project_id) + async def _handle_agent_app_sharing_info( + self, project_id: str, error: Optional[Dict[str, str]] = None + ) -> None: + """Broadcast the project's share links: {channel: link | None}. A + link carries its secret — the bare URL admits nobody.""" await self._broadcast( { "type": "agent_app_sharing_info", "data": { "projectId": project_id, - "lanUrl": lan_url, - # The share link (tunnel URL + secret): the bare tunnel - # URL admits nobody. - "tunnelUrl": self._agent_app_manager.get_tunnel_share_url( - project_id - ), + "links": self._agent_app_manager.share_links(project_id), + "error": error, }, } ) - async def _handle_agent_app_tunnel_start( - self, project_id: str, provider: str + async def _handle_agent_app_share( + self, project_id: str, channel: str, open_it: bool ) -> None: - """Start a tunnel for a Agent App project.""" - logger.info( - f"[AGENT_APP] Tunnel start requested: project={project_id}, provider={provider}" - ) + """Open or close one share channel ("lan" | "tunnel"), then answer + with the current links (and why, if opening failed).""" + error = None try: - url = await self._agent_app_manager.start_tunnel(project_id, provider) - await self._broadcast( - { - "type": "agent_app_tunnel_status", - "data": { - "projectId": project_id, - "tunnelUrl": url, - "success": url is not None, - "error": None if url else f"Failed to start {provider} tunnel", - }, - } - ) + if open_it: + await self._agent_app_manager.open_share(project_id, channel) + else: + await self._agent_app_manager.close_share(project_id, channel) except Exception as e: - logger.error(f"[AGENT_APP] Tunnel start error: {e}", exc_info=True) - await self._broadcast( - { - "type": "agent_app_tunnel_status", - "data": { - "projectId": project_id, - "tunnelUrl": None, - "success": False, - "error": str(e), - }, - } + logger.error( + f"[AGENT_APP] Share {channel} {'open' if open_it else 'close'} " + f"failed for {project_id}: {e}", + exc_info=not isinstance(e, ShareError), ) - - async def _handle_agent_app_tunnel_stop(self, project_id: str) -> None: - """Stop a tunnel for a Agent App project.""" - await self._agent_app_manager.stop_tunnel(project_id) - await self._broadcast( - { - "type": "agent_app_tunnel_status", - "data": { - "projectId": project_id, - "tunnelUrl": None, - "success": True, - }, - } - ) + error = {"channel": channel, "message": str(e)} + await self._handle_agent_app_sharing_info(project_id, error) async def broadcast_agent_app_ready( self, project_id: str, url: str, port: int diff --git a/app/ui_layer/browser/frontend/src/locales/en/settings.json b/app/ui_layer/browser/frontend/src/locales/en/settings.json index d5c54d4b..44bcc9ff 100644 --- a/app/ui_layer/browser/frontend/src/locales/en/settings.json +++ b/app/ui_layer/browser/frontend/src/locales/en/settings.json @@ -683,7 +683,9 @@ "deleteBackupMessage": "Permanently delete the backup from {{date}}?", "lan": "LAN", "public": "Public", - "notShared": "Not shared", + "lanHint": "Devices on your network, with the link", + "publicHint": "Anyone on the internet, with the link", + "createLanLink": "Create LAN Link", "starting": "Starting...", "createTunnel": "Create Tunnel" } diff --git a/app/ui_layer/browser/frontend/src/locales/es/settings.json b/app/ui_layer/browser/frontend/src/locales/es/settings.json index 06b9e4f5..7b5e26ef 100644 --- a/app/ui_layer/browser/frontend/src/locales/es/settings.json +++ b/app/ui_layer/browser/frontend/src/locales/es/settings.json @@ -683,7 +683,9 @@ "deleteBackupMessage": "¿Eliminar permanentemente la copia de seguridad del {{date}}?", "lan": "LAN", "public": "Público", - "notShared": "No compartido", + "lanHint": "Dispositivos de tu red, con el enlace", + "publicHint": "Cualquiera en internet, con el enlace", + "createLanLink": "Crear enlace LAN", "starting": "Iniciando...", "createTunnel": "Crear túnel" } diff --git a/app/ui_layer/browser/frontend/src/locales/id/settings.json b/app/ui_layer/browser/frontend/src/locales/id/settings.json index c8eec9bd..3953ee3f 100644 --- a/app/ui_layer/browser/frontend/src/locales/id/settings.json +++ b/app/ui_layer/browser/frontend/src/locales/id/settings.json @@ -683,7 +683,9 @@ "deleteBackupMessage": "Hapus permanen cadangan dari {{date}}?", "lan": "LAN", "public": "Publik", - "notShared": "Tidak dibagikan", + "lanHint": "Perangkat di jaringan Anda, dengan tautan", + "publicHint": "Siapa pun di internet, dengan tautan", + "createLanLink": "Buat Tautan LAN", "starting": "Memulai...", "createTunnel": "Buat Tunnel" } diff --git a/app/ui_layer/browser/frontend/src/locales/ja/settings.json b/app/ui_layer/browser/frontend/src/locales/ja/settings.json index 78e92ac6..7787f8fa 100644 --- a/app/ui_layer/browser/frontend/src/locales/ja/settings.json +++ b/app/ui_layer/browser/frontend/src/locales/ja/settings.json @@ -683,7 +683,9 @@ "deleteBackupMessage": "{{date}} のバックアップを完全に削除しますか?", "lan": "LAN", "public": "公開", - "notShared": "未共有", + "lanHint": "同じネットワーク上の端末(リンクが必要)", + "publicHint": "インターネット上の誰でも(リンクが必要)", + "createLanLink": "LANリンクを作成", "starting": "起動中...", "createTunnel": "トンネルを作成" } diff --git a/app/ui_layer/browser/frontend/src/locales/ko/settings.json b/app/ui_layer/browser/frontend/src/locales/ko/settings.json index fedbfb58..06215109 100644 --- a/app/ui_layer/browser/frontend/src/locales/ko/settings.json +++ b/app/ui_layer/browser/frontend/src/locales/ko/settings.json @@ -683,7 +683,9 @@ "deleteBackupMessage": "{{date}} 백업을 영구적으로 삭제하시겠습니까?", "lan": "LAN", "public": "공개", - "notShared": "공유되지 않음", + "lanHint": "같은 네트워크의 기기 (링크 필요)", + "publicHint": "인터넷의 누구나 (링크 필요)", + "createLanLink": "LAN 링크 만들기", "starting": "시작하는 중...", "createTunnel": "터널 만들기" } diff --git a/app/ui_layer/browser/frontend/src/locales/zh-CN/settings.json b/app/ui_layer/browser/frontend/src/locales/zh-CN/settings.json index 41af81ed..88c3c88c 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-CN/settings.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-CN/settings.json @@ -683,7 +683,9 @@ "deleteBackupMessage": "永久删除 {{date}} 的备份吗?", "lan": "局域网", "public": "公开", - "notShared": "未分享", + "lanHint": "同一网络中的设备(需凭链接)", + "publicHint": "互联网上的任何人(需凭链接)", + "createLanLink": "创建局域网链接", "starting": "启动中…", "createTunnel": "创建隧道" } diff --git a/app/ui_layer/browser/frontend/src/locales/zh-TW/settings.json b/app/ui_layer/browser/frontend/src/locales/zh-TW/settings.json index a3ed4da6..108b006f 100644 --- a/app/ui_layer/browser/frontend/src/locales/zh-TW/settings.json +++ b/app/ui_layer/browser/frontend/src/locales/zh-TW/settings.json @@ -683,7 +683,9 @@ "deleteBackupMessage": "要永久刪除 {{date}} 的備份嗎?", "lan": "區域網路", "public": "公開", - "notShared": "未分享", + "lanHint": "同一網路中的裝置(需憑連結)", + "publicHint": "網際網路上的任何人(需憑連結)", + "createLanLink": "建立區域網路連結", "starting": "啟動中…", "createTunnel": "建立通道" } diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/AgentAppSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/AgentAppSettings.tsx index c61b24e5..9c9104db 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/AgentAppSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/AgentAppSettings.tsx @@ -549,7 +549,7 @@ function ProjectCard({
{t('settings:agentApp.share')}
- + )} @@ -1108,50 +1108,57 @@ function BackupsSection({ project, onToggleSetting, send }: BackupsSectionProps) interface ShareSectionProps { projectId: string - port: number | null send: (type: string, data?: Record) => void onMessage: (type: string, handler: (data: unknown) => void) => () => void } +type ShareChannel = 'lan' | 'tunnel' + +interface SharingInfo { + projectId: string + links: Partial> + error: { channel: ShareChannel; message: string } | null +} + +// One row per channel, in display order. Both are shared the same way — by +// a link that carries its secret; closing a channel revokes every visitor. +const SHARE_CHANNELS = [ + { id: 'lan', label: 'settings:agentApp.lan', hint: 'settings:agentApp.lanHint', create: 'settings:agentApp.createLanLink' }, + { id: 'tunnel', label: 'settings:agentApp.public', hint: 'settings:agentApp.publicHint', create: 'settings:agentApp.createTunnel' }, +] as const satisfies readonly { id: ShareChannel; label: string; hint: string; create: string }[] + function ShareSection({ projectId, send, onMessage }: ShareSectionProps) { const { t } = useTranslation(['settings', 'common']) - const [lanUrl, setLanUrl] = useState(null) - const [tunnelUrl, setTunnelUrl] = useState(null) - const [tunnelLoading, setTunnelLoading] = useState(false) - const [copied, setCopied] = useState(null) + const [links, setLinks] = useState({}) + const [error, setError] = useState(null) + const [pending, setPending] = useState(null) + const [copied, setCopied] = useState(null) useEffect(() => { send('agent_app_sharing_info', { projectId }) - - const unsub1 = onMessage('agent_app_sharing_info', (data: any) => { - if (data.projectId === projectId) { - setLanUrl(data.lanUrl) - setTunnelUrl(data.tunnelUrl) - } - }) - const unsub2 = onMessage('agent_app_tunnel_status', (data: any) => { - if (data.projectId === projectId) { - setTunnelUrl(data.tunnelUrl) - setTunnelLoading(false) - } + return onMessage('agent_app_sharing_info', (raw: unknown) => { + const data = raw as SharingInfo + if (data.projectId !== projectId) return + setLinks(data.links ?? {}) + setError(data.error ?? null) + setPending(null) }) - return () => { unsub1(); unsub2() } }, [projectId, send, onMessage]) - const handleCopy = (url: string, label: string) => { + const handleCopy = (url: string, channel: ShareChannel) => { navigator.clipboard.writeText(url) - setCopied(label) + setCopied(channel) setTimeout(() => setCopied(null), 2000) } - const handleStartTunnel = () => { - setTunnelLoading(true) - send('agent_app_tunnel_start', { projectId, provider: 'cloudflared' }) + const handleOpen = (channel: ShareChannel) => { + setPending(channel) + setError(null) + send('agent_app_share_open', { projectId, channel }) } - const handleStopTunnel = () => { - send('agent_app_tunnel_stop', { projectId }) - setTunnelUrl(null) + const handleClose = (channel: ShareChannel) => { + send('agent_app_share_close', { projectId, channel }) } return ( @@ -1162,65 +1169,53 @@ function ShareSection({ projectId, send, onMessage }: ShareSectionProps) { gap: 'var(--space-2)', }} > - {/* LAN URL */} - {lanUrl && ( -
- {t('settings:agentApp.lan')} - - {lanUrl} - -
- )} - - {/* Tunnel URL */} - {tunnelUrl ? ( -
- {t('settings:agentApp.public')} - - {tunnelUrl} - -
- ) : ( -
- {t('settings:agentApp.public')} - - {t('settings:agentApp.notShared')} - - -
- )} + {SHARE_CHANNELS.map(({ id, label, hint, create }) => { + const link = links[id] + return ( +
+
+ {t(label)} + {link ? ( + <> + + {link} + + + + )} +
+ {error?.channel === id && ( + {error.message} + )} +
+ ) + })} ) } From 1fd07a8df72bbb25a225025a69ded178ac5b52c6 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Tue, 22 Sep 2026 10:45:22 +0100 Subject: [PATCH 5/5] fix: validate checks --- agent-app/tools/src/commands/validate.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/agent-app/tools/src/commands/validate.ts b/agent-app/tools/src/commands/validate.ts index 90f0f8bd..31dad08e 100644 --- a/agent-app/tools/src/commands/validate.ts +++ b/agent-app/tools/src/commands/validate.ts @@ -112,6 +112,7 @@ interface Operation { name?: unknown; description?: unknown; system?: unknown; + destructive?: unknown; params?: unknown; executor?: { type?: unknown;