diff --git a/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js b/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js index 285c535d..81061a44 100644 --- a/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js +++ b/agent-app/blueprint/pb/pb_hooks/_a2app_lib.js @@ -505,8 +505,278 @@ 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 — + * 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 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. + */ + +// 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', + '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'; +// 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 { + 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 ''; + } +} + +/** 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 isRemoteRequest(e) { + for (var i = 0; i < REMOTE_MARKER_HEADERS.length; i++) { + if (headerOf(e, REMOTE_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; 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. +// 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) { + 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 a share channel, 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 remote = isRemoteRequest(e); + var mutating = method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE'; + 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; + } + + var token = readProjectSecret('.agent-token'); + if (token === '') { + // Locally, a missing token must not lock the app out (it is minted at + // 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: { + 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(); + if (presented !== '' && $security.equal(presented, token)) return null; + var cookie = cookieOf(e, sessionCookieName(token)); + 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 (remote) { + 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 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 = ''; + 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 === '' || !isRemoteRequest(e)) return false; + + 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', + 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 = 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); + 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 (isRemoteRequest(e)) return; + var token = readProjectSecret('.agent-token'); + var value = localSessionValue(token); + 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, + 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 fe9c1497..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 @@ -100,6 +91,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 @@ -134,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(); @@ -202,66 +196,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(); }); @@ -318,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/agent-app/tools/src/commands/validate.ts b/agent-app/tools/src/commands/validate.ts index 0b671063..31dad08e 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'; @@ -112,6 +112,7 @@ interface Operation { name?: unknown; description?: unknown; system?: unknown; + destructive?: unknown; params?: unknown; executor?: { type?: unknown; @@ -702,6 +703,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 33c03470..8851cc33 100644 --- a/app/agent_app/a2app_proxy.py +++ b/app/agent_app/a2app_proxy.py @@ -16,18 +16,27 @@ * /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: +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. """ +import hashlib +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, Optional, Tuple +from typing import Any, Dict, List, Mapping, Optional, Tuple from urllib.parse import quote, urlencode try: @@ -60,6 +69,234 @@ 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 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", + "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" +# 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 + + +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 "" + + +@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 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 + return any( + s.origin and origin.lower() == s.origin.lower() + for s in open_shares(project_dir) + ) + + +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 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()) + + +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] + + +# 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 "" + + +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, 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 secure 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 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), remote requests are + refused (503 share_unavailable) — fail closed, never publicly writable. + """ + 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.", + } + + remote = is_remote_request(headers) + # Preflights never carry credentials (browsers strip them by spec). + 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). 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, + "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( + (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 + cookie = cookies.get(session_cookie_name(expected), "") + 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 remote: + 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 +489,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 +530,86 @@ 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_remote_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 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 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 + + share = match_share(self.project_dir, request.query.get(SHARE_PARAM, "")) + if share is None: + 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 = share_session_value(token, share.secret) + if value: + resp.headers["Set-Cookie"] = session_cookie_header( + session_cookie_name(token), value, secure=share.secure + ) + 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_remote_request(request.headers): + return None + token = _read_secret(self.project_dir, ".agent-token") + 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, secure=False) + # ── A2App endpoints ──────────────────────────────────────────────────── def _identity(self, request): @@ -378,7 +667,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 +677,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 +891,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..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,28 +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.request - - req = urllib.request.Request( - saved_tunnel, method="HEAD" - ) - urllib.request.urlopen(req, timeout=3) - 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 @@ -3158,6 +3138,7 @@ async def _convert_tree( "token.json", ".superuser", ".agent-token", + *SHARE_STATE_FILES, ".jwt_secret", ".npmrc", ".netrc", @@ -3329,7 +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) + 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") @@ -4429,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": @@ -4571,9 +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", + *SHARE_STATE_FILES, } with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: @@ -4599,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`. @@ -4631,345 +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", - } + raise ShareError("The app has no port to share.") + return await self.sharing.open(project, channel, port) - 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 - - 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 - - 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 + if project: + await self.sharing.close(project, channel) - 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}") - return url - 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 - - 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}") - - 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/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/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 e2c00b51..c86ee273 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, @@ -31,6 +32,7 @@ PROXY_PORT = 18471 UPSTREAM_PORT = 18472 +LAN_PORT = 18473 TOKEN = "test-agent-token" @@ -139,6 +141,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") @@ -230,6 +245,253 @@ 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 _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 _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 @@ -244,7 +506,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 +564,9 @@ 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) + 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: @@ -339,7 +583,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..371026d8 --- /dev/null +++ b/app/agent_app/test_a2app_native_auth.py @@ -0,0 +1,352 @@ +"""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 + + # ── 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 _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(): + 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) + _lan_suite(base, proj) + 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_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 37fb124e..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,21 +124,37 @@ 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" - mgr._publish_tunnel_origin(project, None) + # 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" + grant.publish(tmp, URL) + assert secret_file.read_text(encoding="utf-8").strip() == secret + assert grant.link(tmp) == f"{URL}/?a2app_share={secret}" + + grant.revoke(tmp) assert not origin_file.exists(), "stopping the tunnel must revoke the grant" - mgr._publish_tunnel_origin(project, None) # idempotent: stop_tunnel runs often + assert not secret_file.exists(), "stopping the tunnel must end every session" + 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. + grant.publish(tmp, 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: @@ -147,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" @@ -186,6 +199,37 @@ 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. Opening a channel refuses, before touching + anything — including a share that is already open.""" + channel, project, _ = _fixture(tmp) + closed = [] + + async def _close(p): + closed.append(p.id) + + channel.close = _close + 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 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 closed, "refused before touching any existing share" + 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) @@ -202,6 +246,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/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index cad8e48f..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,68 +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) - project = self._agent_app_manager.get_project(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, - "tunnelUrl": project.tunnel_url if project else None, + "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/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) { 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} + )} +
+ ) + })} ) }