diff --git a/docs/cloud-bridge-browser-approval.md b/docs/cloud-bridge-browser-approval.md new file mode 100644 index 000000000..e71c8f32e --- /dev/null +++ b/docs/cloud-bridge-browser-approval.md @@ -0,0 +1,134 @@ +# Cloud Bridge browser registration & approval + +A backend can require explicit approval of a WebBrain installation before it is allowed to run `cloud_*` commands. The feature is opt-in: **without a Cloud Bridge token the bridge behaves exactly as before** (local MCP server, no approval step). + +## Architecture + +``` +background (cloud-runs.js) --cloud-bridge-start {url, token, browserId, installationId, extensionVersion}--> offscreen (cloud-bridge.js) <== WebSocket ==> backend +``` + +```mermaid +flowchart LR + subgraph Browser["User's browser (Chrome)"] + direction TB + S["Settings → Cloud Bridge
URL · token · browser name"] + ST[("chrome.storage.local
url, token, browserId, installationId")] + BG["Background (cloud-runs.js)
reads config, runs cloud_*"] + OFF["Offscreen (cloud-bridge.js)
WebSocket + approval state"] + AG["Agent WebBrain
+ permissions navigate / click…"] + TAB["Web tabs
(user's sessions)"] + S --> ST --> BG + BG -- "cloud-bridge-start + identité" --> OFF + OFF -- "approved commands" --> BG + BG --> AG --> TAB + end + subgraph Backend["Backend (today: local ws:// only)"] + SRV["WebSocket server
checks token
approves / rejects"] + end + OFF <== "WebSocket opened by the extension" ==> SRV +``` + +The French guide ([cloud-bridge-test-guide.fr.md](cloud-bridge-test-guide.fr.md)) also has a message-sequence diagram. + +**Chrome vs Firefox.** Chrome (MV3) hosts the socket in an offscreen document (`src/chrome/src/offscreen/cloud-bridge.js`). Firefox (MV2) has no offscreen documents, so `src/firefox/src/cloud-bridge.js` is a module that runs in the persistent background page and hands commands directly to `handleMessage`. The protocol, storage keys, approval rules and Settings tab are identical; `src/firefox/src/cloud-runs.js` mirrors the Chrome controller and receives the bridge as an injected dependency. + +- `cloud-runs.js` reads the persistent identity from `chrome.storage.local` and passes it to the offscreen page with the existing `cloud-bridge-start` message (the offscreen page has no storage access). +- `offscreen/cloud-bridge.js` sends the enriched `hello` and tracks the approval state **per socket**. +- Command routing (`cloud_run`, `cloud_status`, `cloud_respond`, `cloud_abort`, workflows, scheduled jobs) and payloads are unchanged. + +## Local configuration + +Keys in `chrome.storage.local` (the existing mechanism; the URL and enable toggle are already in Settings): + +| Key | Meaning | +| --- | --- | +| `webbrainCloudBridgeEnabled` | existing toggle | +| `webbrainCloudBridgeUrl` | existing, `ws://` on localhost only | +| `webbrainCloudBridgeToken` | Cloud Bridge credential. Distinct from provider API keys. Setting it turns approval on. | +| `webbrainCloudBridgeBrowserId` | Backend-visible name; defaults to the installation id | +| `webbrainCloudBridgeInstallationId` | Generated once (`crypto.randomUUID()`) | + +All of these are editable in **Settings → Cloud Bridge**: enable toggle, backend URL, token, browser name (`browserId`), the read-only installation ID, and a **Save & test connection** button. The test saves the form, (re)starts the bridge and reports one of: backend unreachable, connected (no approval needed), waiting for approval, approved, or rejected (with the reason). + +To try it end to end: run `node examples/cloud-bridge-approval-server.mjs --token dev-token`, enter `dev-token` in the tab, press **Save & test connection** (status: waiting for approval), then type `approve` in the server terminal (status: approved) or `reject`. + +The older Display → MCP block controls the same enable/URL keys; it is not refreshed live when the new tab changes them (reload Settings). + +Changing the token or browserId reconnects the socket. + +## Messages + +Extension → backend, on every socket open: + +```json +{ + "type": "hello", + "client": "webbrain-extension", + "protocolVersion": 2, + "capabilities": ["saved_workflows_v1", "run_modes_v1", "scheduled_jobs_v1"], + "auth": { "type": "bearer", "token": "dev-token" }, + "browserId": "my-laptop", + "installationId": "0b0c6a3e-…", + "browser": { "name": "Chrome", "version": "126.0.0.0" }, + "extensionVersion": "38.0.13", + "platform": "Linux x86_64", + "status": { "enabled": true, "approval": "pending", "…": "…" } +} +``` + +`auth`, `browserId` and `installationId` are only sent when a token is configured. The token is never included in `status`. + +Backend → extension: + +```json +{ "type": "connection_pending", "browserId": "my-laptop" } +{ "type": "connection_approved", "browserId": "my-laptop" } +{ "type": "connection_rejected", "browserId": "my-laptop", "reason": "Rejected by user" } +``` + +- `browserId` is optional; if present and different from the local one, the message is ignored. +- `connection_rejected` closes the socket and **stops auto-reconnect** until the settings change or the bridge is restarted. + +Until `connection_approved`, any `cloud_*` command gets (and is not forwarded to the background): + +```json +{ "id": "c1", "ok": false, "error": "Connection not approved", "code": "connection_not_approved", "status": 403 } +``` + +After approval, commands are exactly the existing format: + +```json +{ "id": "c2", "action": "cloud_status", "payload": { "runId": "run_123" } } +``` + +`status().approval` is one of `not_required | pending | approved | rejected`. + +## Reconnection + +Approval belongs to one WebSocket. Every new socket (reconnect, URL/identity change) starts `pending`, resends `hello`, and must be approved again. + +## Local test server + +`examples/cloud-bridge-approval-server.mjs` is a dependency-free backend for testing: + +```bash +node examples/cloud-bridge-approval-server.mjs --token dev-token # manual approval +node examples/cloud-bridge-approval-server.mjs --token dev-token --auto-approve +``` + +It replies `connection_pending` after a valid `hello` (or `connection_rejected` for a bad token). On stdin, type `approve`, `reject`, or `send {"id":"1","action":"cloud_status","payload":{"runId":"run_x"}}`. + +Automated tests (unit with a fake WebSocket + one end-to-end run against that server): + +```bash +npm run test:cloud-bridge-approval +``` + +## Limits & security + +- The bridge URL is still restricted to `ws://` on localhost/127.0.0.1/::1. A remote backend needs a local relay, or a deliberate relaxation (needs TLS and a decision upstream). +- The token travels in the `hello` over that local socket; it is stored in plain `chrome.storage.local` like other settings. +- The backend is responsible for validating the token and for the user-facing approval UI; the extension only enforces "no approval, no commands". +- The extension still only accepts the `cloud_*` run actions; no cookies, tabs, history, provider keys or configuration are exposed. +- Approval is not persisted and not revocable from the extension side other than by disabling the bridge or rotating the token. diff --git a/docs/cloud-bridge-test-guide.fr.md b/docs/cloud-bridge-test-guide.fr.md new file mode 100644 index 000000000..e6419f767 --- /dev/null +++ b/docs/cloud-bridge-test-guide.fr.md @@ -0,0 +1,237 @@ +# Guide de test (FR) — Cloud Bridge : enregistrement et approbation du navigateur + +Guide pas à pas pour installer l'extension en mode développeur et tester la feature de bout en bout. Pour la description technique du protocole, voir [cloud-bridge-browser-approval.md](cloud-bridge-browser-approval.md). + +## Schémas + +### Architecture + +```mermaid +flowchart LR + subgraph Browser["Navigateur de l'utilisateur (Chrome)"] + direction TB + S["Settings → Cloud Bridge
URL · token · nom du navigateur"] + ST[("chrome.storage.local
url, token, browserId, installationId")] + BG["Background (cloud-runs.js)
lit la config, exécute les cloud_*"] + OFF["Offscreen (cloud-bridge.js)
socket WebSocket + état d'approbation"] + AG["Agent WebBrain
+ permissions navigate / click…"] + TAB["Onglets web
(sessions de l'utilisateur)"] + S --> ST --> BG + BG -- "cloud-bridge-start + identité" --> OFF + OFF -- "commandes approuvées" --> BG + BG --> AG --> TAB + end + subgraph Backend["Backend (aujourd'hui : ws:// en local uniquement)"] + SRV["Serveur WebSocket
vérifie le token
approuve / refuse"] + end + OFF <== "WebSocket initié par l'extension" ==> SRV +``` + +### Échange de messages + +```mermaid +sequenceDiagram + participant E as Extension (offscreen) + participant B as Backend + participant U as Utilisateur + E->>B: connexion WebSocket + E->>B: hello {auth.token, browserId, installationId, navigateur, version, plateforme, capabilities} + alt token invalide + B-->>E: connection_rejected + Note over E: socket fermée, pas de reconnexion auto + else token valide + B-->>E: connection_pending + B->>E: cloud_run (avant approbation) + E-->>B: ok:false, connection_not_approved (403) + B->>U: demande de validation du navigateur + U-->>B: approuve + B-->>E: connection_approved + B->>E: cloud_run {task} + E-->>B: runId, status running + Note over E: l'agent agit dans le navigateur + E-->>B: needs_user_input (permission navigate) + B->>E: cloud_respond {answer: once} + B->>E: cloud_status {runId} + E-->>B: status completed + résultat + end + Note over E,B: coupure réseau → nouvelle socket → nouveau hello → repart en pending +``` + +## 1. Prérequis + +- Node.js 22 ou plus (`node -v`) +- Google Chrome (ou Chromium) +- Le dépôt, sur la branche de la feature : + +```bash +cd ~/Documents/webbrain +git checkout feat/cloud-bridge-browser-approval +npm ci --ignore-scripts # une seule fois, installe les dépendances de dev +``` + +## 2. Installer l'extension en mode développeur + +L'extension Chrome est directement chargeable depuis `src/chrome` (pas de build nécessaire). + +1. Ouvrir `chrome://extensions`. +2. Activer **Mode développeur** (interrupteur en haut à droite). +3. Cliquer sur **Charger l'extension non empaquetée**. +4. Sélectionner le dossier `~/Documents/webbrain/src/chrome` (celui qui contient `manifest.json`). +5. WebBrain apparaît dans la liste. Épingler l'icône dans la barre d'outils si besoin. + +> Après chaque modification du code : sur `chrome://extensions`, cliquer sur l'icône ↻ de WebBrain, puis recharger la page Settings. + +### Firefox + +1. Ouvrir `about:debugging#/runtime/this-firefox`. +2. **Charger un module complémentaire temporaire…** et choisir `~/Documents/webbrain/src/firefox/manifest.json`. +3. Ouvrir les préférences de WebBrain (`about:addons` → WebBrain → Préférences, ou la page Settings du panneau latéral) et aller dans l'onglet **Cloud Bridge**. Le reste du guide (serveur de test, scénarios A à F) est identique. + +Le module est temporaire : il disparaît au redémarrage de Firefox. Sur Firefox, la socket vit dans la page d'arrière-plan (pas de document offscreen) ; pour déboguer, `about:debugging` → WebBrain → **Inspecter**. + +Alternative (copie construite) : `npm run build:chrome` génère `build/chrome`, à charger de la même façon. + +## 3. Lancer le serveur de test local + +Dans un terminal (le laisser ouvert) : + +```bash +cd ~/Documents/webbrain +node examples/cloud-bridge-approval-server.mjs --token dev-token +``` + +Vous devez voir : `Listening on ws://127.0.0.1:17374/extension`. + +Options : `--port 17374` (défaut), `--auto-approve` (approuve automatiquement, pratique pour tester les commandes). + +Ce terminal sert à la fois de **backend** (il affiche tout ce que l'extension envoie, préfixé par `<-`) et de **console d'approbation**. + +## 4. Configurer l'extension + +1. Ouvrir les Settings de WebBrain (clic droit sur l'icône → Options, ou la roue dentée du panneau latéral). +2. Aller dans l'onglet **Cloud Bridge**. +3. Remplir : + - **Backend WebSocket URL** : `ws://127.0.0.1:17374/extension` (seul `ws://` en local est accepté) + - **Cloud Bridge token** : `dev-token` (le même que `--token` du serveur) + - **Browser name** : par exemple `mon-chrome` (optionnel ; sinon l'ID d'installation sert de nom) +4. Cliquer sur **Save & test connection** (cela active aussi l'interrupteur). + +Le statut passe à : *Connected — waiting for the backend to approve this browser.* + +Dans le terminal du serveur, vous voyez le `hello` reçu, par exemple : + +```json +{"type":"hello","client":"webbrain-extension","protocolVersion":2, + "capabilities":["saved_workflows_v1","run_modes_v1","scheduled_jobs_v1"], + "auth":{"type":"bearer","token":"dev-token"}, + "browserId":"mon-chrome","installationId":"…", + "browser":{"name":"Chrome","version":"…"},"extensionVersion":"38.0.13","platform":"Linux", …} +``` + +## 5. Scénarios à tester + +### A. Commande avant approbation (doit être refusée) + +Dans le terminal du serveur, taper : + +``` +send {"id":"1","action":"cloud_status","payload":{"runId":"x"}} +``` + +Réponse attendue (affichée par le serveur) : + +```json +{"id":"1","ok":false,"error":"Connection not approved","code":"connection_not_approved","status":403} +``` + +### B. Approbation, puis commande + +``` +approve +``` + +Le statut dans Settings devient *Connected and approved.* (il se rafraîchit toutes les 2 s). Puis : + +``` +send {"id":"2","action":"cloud_status","payload":{"runId":"x"}} +``` + +Réponse attendue : `"ok":false,"error":"Unknown cloud run."` — c'est normal, le run `x` n'existe pas ; ce qui compte est que la commande a **atteint** WebBrain (plus de `connection_not_approved`). + +Pour lancer un vrai run, voir le format existant de `cloud_run` dans la doc technique (même payload qu'avant la feature). + +### C. Refus + +Dans le terminal : `reject`. Le statut devient *Rejected by the backend: …*, la socket est fermée et l'extension **ne se reconnecte plus** toute seule. Pour réessayer : **Save & test connection**. + +### D. Reconnexion + +1. Arrêter le serveur (Ctrl+C) puis le relancer : l'extension se reconnecte automatiquement (délai croissant, jusqu'à 30 s). +2. Elle renvoie un `hello`, et la connexion est de nouveau **en attente** : toute commande est refusée tant que vous n'avez pas retapé `approve`. + +Note connue : juste après une reconnexion, le texte de statut de la page Settings peut rester sur « approved » alors que la connexion est repassée en attente ; les commandes sont bien refusées. Cliquer sur **Save & test connection** pour resynchroniser l'affichage. + +### E. Mauvais token + +Mettre un autre token dans Settings puis **Save & test connection** : le serveur répond `connection_rejected` (« Invalid token »). + +### F. Mode historique (sans token) + +Vider le champ token et sauvegarder : plus aucune approbation n'est exigée, le comportement est celui d'avant la feature (serveur MCP local inchangé). Le serveur de test ne l'acceptera pas (il exige un token) ; ce mode se teste avec le serveur MCP du dépôt (`mcp-server/`). + +## 6. Inspecter / déboguer + +- **Console du service worker** : `chrome://extensions` → WebBrain → *service worker* → Console. +- **Voir la config stockée** (même console) : + +```js +chrome.storage.local.get(null, v => console.log( + Object.fromEntries(Object.entries(v).filter(([k]) => k.startsWith('webbrainCloudBridge'))))) +``` + +- **Statut du bridge** : dans cette console, `chrome.runtime.sendMessage({target:'background', action:'cloud_bridge_status'}).then(console.log)` — renvoie `approval` (`not_required | pending | approved | rejected`), jamais le token. +- **Repartir de zéro** : `chrome.storage.local.remove(['webbrainCloudBridgeToken','webbrainCloudBridgeBrowserId','webbrainCloudBridgeInstallationId','webbrainCloudBridgeEnabled'])`. + +Problèmes fréquents : + +| Symptôme | Cause probable | +| --- | --- | +| *Backend unreachable* | Le serveur n'est pas lancé, mauvais port, ou URL autre que `ws://127.0.0.1/localhost` | +| Rien ne change après modification du code | Extension non rechargée (↻ sur `chrome://extensions`) | +| Reste « Rejected » | Normal : pas de reconnexion auto après un refus, cliquer sur **Save & test connection** | +| Port 17374 déjà utilisé | Un serveur MCP tourne déjà : l'arrêter ou utiliser `--port` (et changer l'URL dans Settings) | + +## 7. Tests automatisés + +```bash +npm run test:cloud-bridge-approval # 18 tests (Chrome + Firefox) : hello, pending, approved, rejected, commande avant/après, reconnexion, minuteur annulé après refus, mode sans token, identité unique, e2e vs serveur d'exemple +node test/run.js # suite complète existante (≈ 2400 tests) +``` + +## 8. Bonnes pratiques de sécurité pour vos essais + +- Utilisez un token de test (`dev-token`), jamais une vraie clé API de provider : le token du Cloud Bridge est distinct. +- Le token est stocké en clair dans `chrome.storage.local` et envoyé dans le `hello` (en local uniquement). +- Le serveur d'exemple est un outil de test local, pas un backend de production. + +## 9. Test réel : lancer une vraie tâche (`cloud_run`) + +Pré-requis côté WebBrain : un **provider LLM configuré** (onglet Providers, avec sa clé API) et un onglet web ouvert (le run s'exécute dans l'onglet actif). Les permissions habituelles de WebBrain s'appliquent comme pour un utilisateur humain. + +Avec le serveur d'exemple, après `approve` : + +``` +run Ouvre example.com et dis-moi le titre de la page +status # liste les runs +status run_xxxxx # état d'un run (l'id est dans la réponse de cloud_run) +``` + +`run ` envoie `{"action":"cloud_run","payload":{"task":"","mode":"act"}}` ; la réponse contient un `runId` et `status: "running"`. Interrogez ensuite avec `cloud_status`. Si l'agent pose une question (`pendingInput`), répondez avec `send {"id":"r1","action":"cloud_respond","payload":{"runId":"run_xxxxx","clarifyId":"…","answer":"…"}}` ; `cloud_abort` (`payload: {"runId":"…"}`) interrompt le run. + +### Avec votre propre backend + +Votre backend doit : +1. **Écouter en WebSocket** sur une URL `ws://127.0.0.1` / `localhost` / `::1` (l'extension se connecte *vers* lui). Un backend distant n'est pas accepté tel quel : utilisez un relais local ou voyez la décision « URL localhost uniquement » dans la doc technique. +2. À la réception du `hello`, vérifier `auth.token`, puis répondre `{"type":"connection_pending"}` puis, après validation côté utilisateur, `{"type":"connection_approved"}` (ou `connection_rejected`). +3. Envoyer ensuite les commandes `{"id","action","payload"}` et lire les réponses `{"id","ok","result"|"error"}`. + diff --git a/examples/cloud-bridge-approval-server.mjs b/examples/cloud-bridge-approval-server.mjs new file mode 100644 index 000000000..aca4983e0 --- /dev/null +++ b/examples/cloud-bridge-approval-server.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Minimal local WebSocket backend for the Cloud Bridge browser approval flow. +// Dependency-free (RFC 6455 text frames only). For local testing, not production. +// +// node examples/cloud-bridge-approval-server.mjs --token dev-token [--port 17374] [--auto-approve] +// +// Interactive commands on stdin once a browser is pending/approved: +// approve | reject | run | status [runId] | send {"id":"1","action":"cloud_status","payload":{"runId":"run_x"}} + +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function encodeFrame(text) { + const payload = Buffer.from(text); + const n = payload.length; + const head = n < 126 ? Buffer.from([0x81, n]) + : n < 65536 ? Buffer.from([0x81, 126, n >> 8, n & 255]) + : Buffer.concat([Buffer.from([0x81, 127]), Buffer.alloc(4), Buffer.from([n >>> 24, (n >> 16) & 255, (n >> 8) & 255, n & 255])]); + return Buffer.concat([head, payload]); +} + +// Returns { messages, rest, close } for the frames buffered so far. +function decodeFrames(buf) { + const messages = []; + let close = false; + while (buf.length >= 2) { + const opcode = buf[0] & 15; + let len = buf[1] & 127; + let off = 2; + if (len === 126) { if (buf.length < 4) break; len = buf.readUInt16BE(2); off = 4; } + else if (len === 127) { if (buf.length < 10) break; len = Number(buf.readBigUInt64BE(2)); off = 10; } + const masked = (buf[1] & 128) !== 0; + if (buf.length < off + (masked ? 4 : 0) + len) break; + const mask = masked ? buf.subarray(off, off + 4) : null; + if (masked) off += 4; + const data = Buffer.from(buf.subarray(off, off + len)); + if (mask) for (let i = 0; i < data.length; i++) data[i] ^= mask[i % 4]; + buf = buf.subarray(off + len); + if (opcode === 8) { close = true; break; } + if (opcode === 1) messages.push(data.toString('utf8')); + } + return { messages, rest: buf, close }; +} + +export function startApprovalServer({ port = 17374, host = '127.0.0.1', token = 'dev-token', autoApprove = false, onMessage = () => {} } = {}) { + const sessions = new Set(); + const server = createServer((_req, res) => { res.writeHead(426).end(); }); + server.on('upgrade', (req, socket) => { + const key = req.headers['sec-websocket-key']; + if (!key) { socket.destroy(); return; } + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${createHash('sha1').update(key + GUID).digest('base64')}`, + '', '', + ].join('\r\n')); + const session = { + socket, + hello: null, + state: 'connected', + send: (obj) => socket.write(encodeFrame(JSON.stringify(obj))), + approve() { + session.state = 'approved'; + session.send({ type: 'connection_approved', browserId: session.hello?.browserId }); + }, + reject(reason = 'Rejected by user') { + session.state = 'rejected'; + session.send({ type: 'connection_rejected', browserId: session.hello?.browserId, reason }); + }, + }; + sessions.add(session); + let buffered = Buffer.alloc(0); + socket.on('data', (chunk) => { + const decoded = decodeFrames(Buffer.concat([buffered, chunk])); + buffered = decoded.rest; + for (const text of decoded.messages) { + let msg; + try { msg = JSON.parse(text); } catch { continue; } + if (msg.type === 'hello') { + session.hello = msg; + if (msg.auth?.token !== token || !msg.browserId) session.reject('Invalid token'); + else { + session.state = 'pending'; + session.send({ type: 'connection_pending', browserId: msg.browserId }); + if (autoApprove) session.approve(); + } + } + onMessage(msg, session); + } + if (decoded.close) socket.end(); + }); + socket.on('close', () => sessions.delete(session)); + socket.on('error', () => sessions.delete(session)); + }); + return new Promise((resolve) => server.listen(port, host, () => resolve({ + port: server.address().port, + sessions, + close: () => new Promise((done) => { + for (const s of sessions) s.socket.destroy(); + server.closeAllConnections?.(); + server.close(done); + }), + }))); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const opt = (name, fallback) => (args.includes(name) ? args[args.indexOf(name) + 1] : fallback); + const srv = await startApprovalServer({ + port: Number(opt('--port', 17374)), + token: opt('--token', 'dev-token'), + autoApprove: args.includes('--auto-approve'), + onMessage: (msg) => console.log('<-', JSON.stringify(msg)), + }); + console.log(`Listening on ws://127.0.0.1:${srv.port}/extension`); + const current = () => [...srv.sessions].at(-1); + createInterface({ input: process.stdin }).on('line', (line) => { + const session = current(); + if (!session) return console.log('No browser connected.'); + if (line === 'approve') session.approve(); + else if (line === 'reject') session.reject(); + else if (line.startsWith('run ')) session.send({ id: `run-${Date.now()}`, action: 'cloud_run', payload: { task: line.slice(4), mode: 'act' } }); + else if (line.startsWith('status')) session.send({ id: `st-${Date.now()}`, action: 'cloud_status', payload: line.slice(6).trim() ? { runId: line.slice(6).trim() } : {} }); + else if (line.startsWith('send ')) session.send(JSON.parse(line.slice(5))); + }); +} diff --git a/package.json b/package.json index 4473ac773..be2b5597f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "test": "npm run test:cms && npm run test:markdown && npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs test/capsolver-cloud-broker.mjs test/captcha-providers.mjs test/captcha-weighted-fallback.mjs test/captcha-hcaptcha-providers.mjs test/captcha-native-providers.mjs test/captcha-runtime-policy.mjs test/captcha-callback-bridge.mjs test/captcha-aws-recovery.mjs test/captcha-documented-contracts.mjs test/huggingface-signup-recovery.mjs test/trace-full-export.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:provider-limits && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:discord && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && npm run test:workflow-editor && node --test test/profile-sync-stats.mjs && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", + "test": "npm run test:cms && npm run test:markdown && npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs test/capsolver-cloud-broker.mjs test/captcha-providers.mjs test/captcha-weighted-fallback.mjs test/captcha-hcaptcha-providers.mjs test/captcha-native-providers.mjs test/captcha-runtime-policy.mjs test/captcha-callback-bridge.mjs test/captcha-aws-recovery.mjs test/captcha-documented-contracts.mjs test/huggingface-signup-recovery.mjs test/trace-full-export.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:provider-limits && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:cloud-bridge-approval && npm run test:discord && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && npm run test:workflow-editor && node --test test/profile-sync-stats.mjs && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", "test:captcha:ui": "node test/captcha-settings-ui.mjs && node test/captcha-application-ui.mjs", "test:captcha:bridge:ui": "node test/captcha-callback-bridge-ui.mjs", "test:provider-limits": "node test/provider-model-limits.mjs", @@ -14,6 +14,7 @@ "test:attachment-drop": "node test/attachment-drop.mjs", "test:workflow-editor": "node --test test/workflow-editor.mjs", "test:security": "node test/security/injection-corpus.mjs", + "test:cloud-bridge-approval": "node --test test/cloud-bridge-approval.mjs", "test:discord": "node --test test/discord-guard.mjs", "test:toolbar-guard": "node test/rich-text-toolbar-guard.mjs", "test:pdf-read": "node test/pdf-read.mjs", diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 95a7fe3ee..19f0d9ebf 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -1212,7 +1212,8 @@ chrome.storage.onChanged.addListener((changes, areaName) => { } if (PROFILE_SYNC_DATA_KEYS.some((key) => changes[key])) profileSync.noteChanges(changes).catch(() => {}); if (changes.providers || changes.activeProvider || changes.helpImproveWebBrain) providerManager.load().catch(() => {}); - if (changes.webbrainCloudBridgeEnabled || changes.webbrainCloudBridgeUrl) { + if (changes.webbrainCloudBridgeEnabled || changes.webbrainCloudBridgeUrl + || changes.webbrainCloudBridgeToken || changes.webbrainCloudBridgeBrowserId) { cloudRunController.syncBridge().catch(() => {}); } if (changes.maxAgentSteps) { diff --git a/src/chrome/src/cloud-runs.js b/src/chrome/src/cloud-runs.js index 68b72707c..6be36b3bc 100644 --- a/src/chrome/src/cloud-runs.js +++ b/src/chrome/src/cloud-runs.js @@ -1391,9 +1391,47 @@ export function createCloudRunController({ return cloudSnapshot(run); } + // Persistent bridge identity (chrome.storage.local). The token is the Cloud + // Bridge credential only; it is unrelated to provider API keys. + // + // Concurrent starts (a settings save triggers syncBridge while the page also + // sends cloud_bridge_start) must not each mint an installation id, so the + // read-or-create step is single-flight. + let installationIdInit = null; + function ensureInstallationId() { + if (!installationIdInit) { + installationIdInit = (async () => { + const stored = await api.storage.local.get('webbrainCloudBridgeInstallationId'); + if (stored.webbrainCloudBridgeInstallationId) return stored.webbrainCloudBridgeInstallationId; + const created = crypto.randomUUID(); + await api.storage.local.set({ webbrainCloudBridgeInstallationId: created }); + return created; + })().finally(() => { installationIdInit = null; }); + } + return installationIdInit; + } + + async function bridgeIdentity() { + const installationId = await ensureInstallationId(); + const stored = await api.storage.local.get([ + 'webbrainCloudBridgeToken', + 'webbrainCloudBridgeBrowserId', + ]); + return { + token: stored.webbrainCloudBridgeToken || '', + browserId: stored.webbrainCloudBridgeBrowserId || installationId, + installationId, + extensionVersion: api.runtime.getManifest?.().version || '', + }; + } + async function startBridge(url = DEFAULT_CLOUD_BRIDGE_URL) { await ensureOffscreen(); - return api.runtime.sendMessage({ type: 'cloud-bridge-start', url: normalizeCloudBridgeUrl(url) }); + return api.runtime.sendMessage({ + type: 'cloud-bridge-start', + url: normalizeCloudBridgeUrl(url), + ...(await bridgeIdentity()), + }); } async function stopBridge() { diff --git a/src/chrome/src/offscreen/cloud-bridge.js b/src/chrome/src/offscreen/cloud-bridge.js index 45708756c..19d9fd488 100644 --- a/src/chrome/src/offscreen/cloud-bridge.js +++ b/src/chrome/src/offscreen/cloud-bridge.js @@ -28,6 +28,24 @@ let reconnectTimer = null; let reconnectAttempt = 0; let lastError = ''; + // Browser registration/approval. Identity is pushed by the background worker + // with `cloud-bridge-start` (the offscreen page has no storage access). When + // a token is configured the backend must approve each new socket before any + // cloud_* command runs; without a token the legacy local behaviour is kept. + let identity = { token: '', browserId: '', installationId: '', extensionVersion: '' }; + let approval = 'not_required'; // not_required | pending | approved | rejected + + function browserInfo() { + const ua = (typeof navigator !== 'undefined' && navigator.userAgent) || ''; + const match = /(Edg|Firefox|Chrome)\/([\d.]+)/.exec(ua); + const names = { Edg: 'Edge', Firefox: 'Firefox', Chrome: 'Chrome' }; + return { name: match ? names[match[1]] : 'unknown', version: match ? match[2] : '' }; + } + + function platformName() { + if (typeof navigator === 'undefined') return ''; + return navigator.userAgentData?.platform || navigator.platform || ''; + } function normalizeBridgeUrl(value) { const url = new URL(String(value || 'ws://127.0.0.1:17374/extension')); @@ -44,6 +62,9 @@ return { enabled, url: bridgeUrl, + browserId: identity.browserId || null, + installationId: identity.installationId || null, + approval, connected: socket?.readyState === WebSocket.OPEN, readyState: socket ? socket.readyState : null, reconnectAttempt, @@ -79,13 +100,24 @@ if (socket !== nextSocket) return; reconnectAttempt = 0; lastError = ''; - sendJson({ + // Every new socket starts unapproved; approval never carries over. + approval = identity.token ? 'pending' : 'not_required'; + const hello = { type: 'hello', client: 'webbrain-extension', protocolVersion: BRIDGE_PROTOCOL_VERSION, capabilities: BRIDGE_CAPABILITIES, + browser: browserInfo(), + extensionVersion: identity.extensionVersion, + platform: platformName(), status: status(), - }, nextSocket); + }; + if (identity.token) { + hello.auth = { type: 'bearer', token: identity.token }; + hello.browserId = identity.browserId; + hello.installationId = identity.installationId; + } + sendJson(hello, nextSocket); }); nextSocket.addEventListener('message', async (event) => { if (socket !== nextSocket) return; @@ -97,6 +129,25 @@ return; } + if (msg.type === 'connection_pending' || msg.type === 'connection_approved' || msg.type === 'connection_rejected') { + if (!identity.token) return; + if (msg.browserId && msg.browserId !== identity.browserId) return; + if (msg.type === 'connection_pending') { + if (approval !== 'approved') approval = 'pending'; + } else if (msg.type === 'connection_approved') { + approval = 'approved'; + lastError = ''; + } else { + approval = 'rejected'; + lastError = String(msg.reason || 'Connection rejected by backend'); + // A backoff timer from an earlier socket must not reconnect a rejected browser. + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; + try { nextSocket.close(); } catch {} + } + return; + } + const id = msg.id || null; const action = msg.action || msg.command; const payload = msg.payload || msg; @@ -109,6 +160,11 @@ return; } + if (identity.token && approval !== 'approved') { + sendJson({ id, ok: false, error: 'Connection not approved', code: 'connection_not_approved', status: 403 }, nextSocket); + return; + } + try { const response = await chrome.runtime.sendMessage({ ...payload, @@ -130,6 +186,9 @@ nextSocket.addEventListener('close', () => { if (socket !== nextSocket) return; socket = null; + // A rejected browser stays rejected until the settings change. + if (approval === 'rejected') return; + approval = identity.token ? 'pending' : 'not_required'; scheduleReconnect(); }); nextSocket.addEventListener('error', () => { @@ -153,9 +212,21 @@ sendResponse({ ...status(), error: lastError }); return false; } - const changed = bridgeUrl && bridgeUrl !== nextUrl; + const nextIdentity = { + token: String(msg.token || ''), + browserId: String(msg.browserId || ''), + installationId: String(msg.installationId || ''), + extensionVersion: String(msg.extensionVersion || ''), + }; + const identityChanged = JSON.stringify(nextIdentity) !== JSON.stringify(identity); + const changed = (bridgeUrl && bridgeUrl !== nextUrl) || identityChanged; enabled = true; bridgeUrl = nextUrl; + identity = nextIdentity; + if (identityChanged) { + approval = identity.token ? 'pending' : 'not_required'; + reconnectAttempt = 0; + } if (changed && socket) { const previousSocket = socket; socket = null; diff --git a/src/chrome/src/ui/locales/ar.js b/src/chrome/src/ui/locales/ar.js index bd174f183..64a9cbdc0 100644 --- a/src/chrome/src/ui/locales/ar.js +++ b/src/chrome/src/ui/locales/ar.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Arabic (ar). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'تكبير واجهة الإضافة', 'sp.ui_scale.decrease': 'تصغير واجهة الإضافة', 'sp.ui_scale.increase': 'تكبير واجهة الإضافة', diff --git a/src/chrome/src/ui/locales/bn.js b/src/chrome/src/ui/locales/bn.js index 638da186e..7f82d980e 100644 --- a/src/chrome/src/ui/locales/bn.js +++ b/src/chrome/src/ui/locales/bn.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Bengali — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'এক্সটেনশনের UI জুম', 'sp.ui_scale.decrease': 'এক্সটেনশনের UI ছোট করুন', 'sp.ui_scale.increase': 'এক্সটেনশনের UI বড় করুন', diff --git a/src/chrome/src/ui/locales/cloud-bridge-copy.mjs b/src/chrome/src/ui/locales/cloud-bridge-copy.mjs new file mode 100644 index 000000000..4df7ff44d --- /dev/null +++ b/src/chrome/src/ui/locales/cloud-bridge-copy.mjs @@ -0,0 +1,22 @@ +// English fallback for the Cloud Bridge settings tab; shared to keep locale keys aligned. +export default { + "st.cb.invalid_url": "Use a local ws:// URL with 127.0.0.1, localhost, or ::1.", + "st.cb.status_disabled": "Bridge disabled", + "st.cb.status_error": "Connection error: {error}", + "st.tab.cloudbridge": 'Cloud Bridge', + "st.cb.enabled": 'Let a backend control this browser', + "st.cb.desc": 'WebBrain connects out to the backend below and waits for cloud_* commands. With a token set, the backend must approve this browser before any command runs.', + "st.cb.url": 'Backend WebSocket URL (local ws:// only)', + "st.cb.token": 'Cloud Bridge token', + "st.cb.token_hint": 'Leave empty for a plain local controller (no approval step). This is not a provider API key.', + "st.cb.browser_name": 'Browser name', + "st.cb.installation_id": 'Installation ID:', + "st.cb.test": 'Save & test connection', + "st.cb.testing": 'Testing…', + "st.cb.connected": 'Connected (no approval required).', + "st.cb.pending": 'Connected — waiting for the backend to approve this browser.', + "st.cb.approved": 'Connected and approved.', + "st.cb.rejected": 'Rejected by the backend: {reason}', + "st.cb.unreachable": 'Backend unreachable at {url}.', + "st.cb.howto_html": 'To try it locally, run node examples/cloud-bridge-approval-server.mjs --token YOUR_TOKEN from the WebBrain checkout, enter the same token here, then press “Save & test connection”. Type approve in that terminal. Documentation', +}; diff --git a/src/chrome/src/ui/locales/de.js b/src/chrome/src/ui/locales/de.js index c8a9eb233..69d9cca3c 100644 --- a/src/chrome/src/ui/locales/de.js +++ b/src/chrome/src/ui/locales/de.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // German (de). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Erweiterungsoberfläche zoomen', 'sp.ui_scale.decrease': 'Erweiterungsoberfläche verkleinern', 'sp.ui_scale.increase': 'Erweiterungsoberfläche vergrößern', diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index 7dd7d1552..b9671a21e 100644 --- a/src/chrome/src/ui/locales/en.js +++ b/src/chrome/src/ui/locales/en.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // English — canonical locale. Other locales inherit key names from this file. import apocalypseModeCopy from './apocalypse-copy.mjs'; import emergencyCopy from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Extension UI zoom', 'sp.ui_scale.decrease': 'Zoom extension UI out', 'sp.ui_scale.increase': 'Zoom extension UI in', diff --git a/src/chrome/src/ui/locales/es.js b/src/chrome/src/ui/locales/es.js index 401c31a78..ed6ad68f6 100644 --- a/src/chrome/src/ui/locales/es.js +++ b/src/chrome/src/ui/locales/es.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Spanish (es). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom de la interfaz de la extensión', 'sp.ui_scale.decrease': 'Reducir el zoom de la interfaz', 'sp.ui_scale.increase': 'Aumentar el zoom de la interfaz', diff --git a/src/chrome/src/ui/locales/fa.js b/src/chrome/src/ui/locales/fa.js index 42e4a1007..b8cd251c9 100644 --- a/src/chrome/src/ui/locales/fa.js +++ b/src/chrome/src/ui/locales/fa.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Persian — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'بزرگ‌نمایی رابط کاربری افزونه', 'sp.ui_scale.decrease': 'کاهش بزرگ‌نمایی رابط افزونه', 'sp.ui_scale.increase': 'افزایش بزرگ‌نمایی رابط افزونه', diff --git a/src/chrome/src/ui/locales/fr.js b/src/chrome/src/ui/locales/fr.js index e97b32b84..51a178e0c 100644 --- a/src/chrome/src/ui/locales/fr.js +++ b/src/chrome/src/ui/locales/fr.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // French (fr). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom de l’interface de l’extension', 'sp.ui_scale.decrease': 'Réduire le zoom de l’interface', 'sp.ui_scale.increase': 'Augmenter le zoom de l’interface', diff --git a/src/chrome/src/ui/locales/he.js b/src/chrome/src/ui/locales/he.js index 6e4ccfb7c..75c357e06 100644 --- a/src/chrome/src/ui/locales/he.js +++ b/src/chrome/src/ui/locales/he.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Hebrew (he). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'הגדלת ממשק התוסף', 'sp.ui_scale.decrease': 'הקטנת תצוגת ממשק התוסף', 'sp.ui_scale.increase': 'הגדלת תצוגת ממשק התוסף', diff --git a/src/chrome/src/ui/locales/hi.js b/src/chrome/src/ui/locales/hi.js index d4e2e4d25..24eedae5c 100644 --- a/src/chrome/src/ui/locales/hi.js +++ b/src/chrome/src/ui/locales/hi.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Hindi — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'एक्सटेंशन UI ज़ूम', 'sp.ui_scale.decrease': 'एक्सटेंशन UI को छोटा करें', 'sp.ui_scale.increase': 'एक्सटेंशन UI को बड़ा करें', diff --git a/src/chrome/src/ui/locales/id.js b/src/chrome/src/ui/locales/id.js index f5eadb61e..dd7062268 100644 --- a/src/chrome/src/ui/locales/id.js +++ b/src/chrome/src/ui/locales/id.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Indonesian (id). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom UI ekstensi', 'sp.ui_scale.decrease': 'Perkecil UI ekstensi', 'sp.ui_scale.increase': 'Perbesar UI ekstensi', diff --git a/src/chrome/src/ui/locales/ja.js b/src/chrome/src/ui/locales/ja.js index ee0e9c5cf..52f8935e7 100644 --- a/src/chrome/src/ui/locales/ja.js +++ b/src/chrome/src/ui/locales/ja.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Japanese (ja). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': '拡張機能 UI のズーム', 'sp.ui_scale.decrease': '拡張機能 UI を縮小', 'sp.ui_scale.increase': '拡張機能 UI を拡大', diff --git a/src/chrome/src/ui/locales/ko.js b/src/chrome/src/ui/locales/ko.js index aaa30d4b6..72e161971 100644 --- a/src/chrome/src/ui/locales/ko.js +++ b/src/chrome/src/ui/locales/ko.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Korean (ko). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': '확장 프로그램 UI 확대/축소', 'sp.ui_scale.decrease': '확장 프로그램 UI 축소', 'sp.ui_scale.increase': '확장 프로그램 UI 확대', diff --git a/src/chrome/src/ui/locales/ms.js b/src/chrome/src/ui/locales/ms.js index 8359ae65e..753ca906a 100644 --- a/src/chrome/src/ui/locales/ms.js +++ b/src/chrome/src/ui/locales/ms.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Malay (ms). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zum UI sambungan', 'sp.ui_scale.decrease': 'Kecilkan UI sambungan', 'sp.ui_scale.increase': 'Besarkan UI sambungan', diff --git a/src/chrome/src/ui/locales/nl.js b/src/chrome/src/ui/locales/nl.js index 25d0f06ae..bfc48f311 100644 --- a/src/chrome/src/ui/locales/nl.js +++ b/src/chrome/src/ui/locales/nl.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Dutch (nl). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom van extensie-interface', 'sp.ui_scale.decrease': 'Extensie-interface uitzoomen', 'sp.ui_scale.increase': 'Extensie-interface inzoomen', diff --git a/src/chrome/src/ui/locales/pl.js b/src/chrome/src/ui/locales/pl.js index 7a3a048dc..9eda77a06 100644 --- a/src/chrome/src/ui/locales/pl.js +++ b/src/chrome/src/ui/locales/pl.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Polski — translated from en.js. Keys mirror the English canonical file. import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Powiększenie interfejsu rozszerzenia', 'sp.ui_scale.decrease': 'Pomniejsz interfejs rozszerzenia', 'sp.ui_scale.increase': 'Powiększ interfejs rozszerzenia', diff --git a/src/chrome/src/ui/locales/pt.js b/src/chrome/src/ui/locales/pt.js index 1c1faf538..54979297b 100644 --- a/src/chrome/src/ui/locales/pt.js +++ b/src/chrome/src/ui/locales/pt.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Portuguese — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom da interface da extensão', 'sp.ui_scale.decrease': 'Reduzir o zoom da interface', 'sp.ui_scale.increase': 'Aumentar o zoom da interface', diff --git a/src/chrome/src/ui/locales/ru.js b/src/chrome/src/ui/locales/ru.js index 82910a1f8..d03c85d5e 100644 --- a/src/chrome/src/ui/locales/ru.js +++ b/src/chrome/src/ui/locales/ru.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Russian (ru). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Масштаб интерфейса расширения', 'sp.ui_scale.decrease': 'Уменьшить интерфейс расширения', 'sp.ui_scale.increase': 'Увеличить интерфейс расширения', diff --git a/src/chrome/src/ui/locales/th.js b/src/chrome/src/ui/locales/th.js index 82bf6e398..0e25d184a 100644 --- a/src/chrome/src/ui/locales/th.js +++ b/src/chrome/src/ui/locales/th.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Thai (th). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'ซูม UI ของส่วนขยาย', 'sp.ui_scale.decrease': 'ย่อ UI ของส่วนขยาย', 'sp.ui_scale.increase': 'ขยาย UI ของส่วนขยาย', diff --git a/src/chrome/src/ui/locales/tl.js b/src/chrome/src/ui/locales/tl.js index 69b6e42ed..6e4ffe9e2 100644 --- a/src/chrome/src/ui/locales/tl.js +++ b/src/chrome/src/ui/locales/tl.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Filipino / Tagalog (tl). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Pag-zoom ng UI ng extension', 'sp.ui_scale.decrease': 'Bawasan ang zoom ng UI ng extension', 'sp.ui_scale.increase': 'Dagdagan ang zoom ng UI ng extension', diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index aee4d10a1..8003eb410 100644 --- a/src/chrome/src/ui/locales/tr.js +++ b/src/chrome/src/ui/locales/tr.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Turkish (tr). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Uzantı arayüzü yakınlaştırması', 'sp.ui_scale.decrease': 'Uzantı arayüzünü uzaklaştır', 'sp.ui_scale.increase': 'Uzantı arayüzünü yakınlaştır', diff --git a/src/chrome/src/ui/locales/uk.js b/src/chrome/src/ui/locales/uk.js index 763b53fb7..aade09141 100644 --- a/src/chrome/src/ui/locales/uk.js +++ b/src/chrome/src/ui/locales/uk.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Ukrainian (uk). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Масштаб інтерфейсу розширення', 'sp.ui_scale.decrease': 'Зменшити інтерфейс розширення', 'sp.ui_scale.increase': 'Збільшити інтерфейс розширення', diff --git a/src/chrome/src/ui/locales/vi.js b/src/chrome/src/ui/locales/vi.js index 5aa09db74..0793e70dd 100644 --- a/src/chrome/src/ui/locales/vi.js +++ b/src/chrome/src/ui/locales/vi.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Vietnamese — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Thu phóng giao diện tiện ích', 'sp.ui_scale.decrease': 'Thu nhỏ giao diện tiện ích', 'sp.ui_scale.increase': 'Phóng to giao diện tiện ích', diff --git a/src/chrome/src/ui/locales/zh.js b/src/chrome/src/ui/locales/zh.js index 7d9a1ba06..d80d9056c 100644 --- a/src/chrome/src/ui/locales/zh.js +++ b/src/chrome/src/ui/locales/zh.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Simplified Chinese (zh). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': '插件界面缩放', 'sp.ui_scale.decrease': '缩小插件界面', 'sp.ui_scale.increase': '放大插件界面', diff --git a/src/chrome/src/ui/settings-cloud-bridge.js b/src/chrome/src/ui/settings-cloud-bridge.js new file mode 100644 index 000000000..c1ea16918 --- /dev/null +++ b/src/chrome/src/ui/settings-cloud-bridge.js @@ -0,0 +1,121 @@ +// Settings → Cloud Bridge tab: URL, token, browser name, connection test. +// Uses the same storage keys as the Display → MCP block; identity keys are +// read by cloud-runs.js when the bridge starts. +import { t } from './i18n.js'; + +const KEYS = { + enabled: 'webbrainCloudBridgeEnabled', + url: 'webbrainCloudBridgeUrl', + token: 'webbrainCloudBridgeToken', + browserId: 'webbrainCloudBridgeBrowserId', + installationId: 'webbrainCloudBridgeInstallationId', +}; +const DEFAULT_URL = 'ws://127.0.0.1:17374/extension'; +const $ = (id) => document.getElementById(id); +const enabled = $('cb-enabled'); +const urlInput = $('cb-url'); +const tokenInput = $('cb-token'); +const nameInput = $('cb-browser-id'); +const installationEl = $('cb-installation-id'); +const statusEl = $('cb-status'); +const statusText = $('cb-status-text'); +const testBtn = $('cb-test'); + +function send(action, data = {}) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ target: 'background', action, ...data }, (response) => { + if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); + else if (response?.error) reject(new Error(response.error)); + else resolve(response); + }); + }); +} + +function setStatus(state, message) { + statusEl.dataset.state = state; + statusText.textContent = message; +} + +function normalizeUrl(value) { + const url = new URL(String(value || DEFAULT_URL)); + if (url.protocol !== 'ws:' || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname.toLowerCase())) { + throw new Error(t('st.cb.invalid_url')); + } + return url.href; +} + +function render(status = {}) { + if (!enabled.checked || status.enabled === false) return setStatus('disabled', t('st.cb.status_disabled')); + if (status.installationId) installationEl.textContent = status.installationId; + if (status.approval === 'rejected') return setStatus('error', t('st.cb.rejected', { reason: status.lastError || '' })); + if (status.connected) { + if (status.approval === 'approved') return setStatus('connected', t('st.cb.approved')); + if (status.approval === 'pending') return setStatus('waiting', t('st.cb.pending')); + return setStatus('connected', t('st.cb.connected')); + } + if (status.lastError && status.lastError !== 'WebSocket error') return setStatus('error', t('st.cb.status_error', { error: status.lastError })); + setStatus('waiting', t('st.cb.unreachable', { url: status.url || urlInput.value || DEFAULT_URL })); +} + +async function load() { + const stored = await chrome.storage.local.get(Object.values(KEYS)); + enabled.checked = !!stored[KEYS.enabled]; + urlInput.value = stored[KEYS.url] || DEFAULT_URL; + tokenInput.value = stored[KEYS.token] || ''; + nameInput.value = stored[KEYS.browserId] || ''; + installationEl.textContent = stored[KEYS.installationId] || '—'; + if (enabled.checked) refresh(); + else render({ enabled: false }); +} + +async function refresh() { + if (!enabled.checked || document.hidden) return; + try { render(await send('cloud_bridge_status')); } catch (e) { setStatus('error', e.message); } +} + +async function save() { + const url = normalizeUrl(urlInput.value); + urlInput.value = url; + const patch = { [KEYS.url]: url, [KEYS.enabled]: enabled.checked, [KEYS.token]: tokenInput.value.trim() }; + const name = nameInput.value.trim(); + await chrome.storage.local.set(patch); + if (name) await chrome.storage.local.set({ [KEYS.browserId]: name }); + else await chrome.storage.local.remove(KEYS.browserId); + return url; +} + +async function testConnection() { + testBtn.disabled = true; + setStatus('waiting', t('st.cb.testing')); + try { + enabled.checked = true; // testing implies connecting + const url = await save(); + let status = await send('cloud_bridge_start', { url }); + for (let i = 0; i < 16; i++) { + await new Promise((r) => setTimeout(r, 500)); + status = await send('cloud_bridge_status'); + if (status.approval === 'rejected' || status.approval === 'approved' || (status.connected && status.approval === 'not_required')) break; + } + render(status); + const stored = await chrome.storage.local.get(KEYS.installationId); + installationEl.textContent = stored[KEYS.installationId] || '—'; + } catch (e) { + setStatus('error', e.message); + } finally { + testBtn.disabled = false; + } +} + +enabled.addEventListener('change', async () => { + try { + await save(); + if (enabled.checked) render(await send('cloud_bridge_start', { url: urlInput.value })); + else { await send('cloud_bridge_stop').catch(() => null); render({ enabled: false }); } + } catch (e) { setStatus('error', e.message); } +}); +testBtn.addEventListener('click', testConnection); +setInterval(refresh, 2000); +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && Object.values(KEYS).some((k) => changes[k]) && !document.activeElement?.closest('#cb-card')) load(); +}); +load(); diff --git a/src/chrome/src/ui/settings.html b/src/chrome/src/ui/settings.html index bc7212510..c4eefcf9f 100644 --- a/src/chrome/src/ui/settings.html +++ b/src/chrome/src/ui/settings.html @@ -1429,6 +1429,7 @@

+
@@ -1719,7 +1720,7 @@

- +
@@ -2497,12 +2498,50 @@

+
+
+
+
+
+
+
+ +
+ + +
+ +
—
+
+ +
+
+ + +
+
+
+
+ + diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 3c07b7d77..668b911b0 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -915,6 +915,7 @@ export class Agent extends LoopDetector { this.pendingAdapterMatchTraces = new Map(); // tabId -> Map(adapter@revision -> content-free match metadata) this.adapterMatchTraceKeys = new Map(); // runId -> Map(adapter@revision -> OR-merged match metadata) this.apiAllowedTabs = new Set(); + this.temporaryApiAllowedTabs = new Set(); // Global Advanced-setting opt-in. Keep it separate from the tab set so // /reset clears only the conversation override and live setting changes // can revoke global permission without erasing explicit /allow-api state. @@ -7224,6 +7225,30 @@ export class Agent extends LoopDetector { } } + setTemporaryApiMutationsAllowed(tabId, allowed) { + if (allowed) { + this.temporaryApiAllowedTabs.add(tabId); + return; + } + + this.temporaryApiAllowedTabs.delete(tabId); + // A user may have enabled /allow-api or the global setting while the run + // was active. In that case the injected authorization is still current. + if (this.isApiMutationsAllowed(tabId)) return; + + if (this.apiAllowedInjected.has(tabId)) { + const messages = this.conversations.get(tabId); + if (Array.isArray(messages)) { + messages.push(this._appOwnedUserMessage( + '[CURRENT API MUTATION AUTHORIZATION — NOT ALLOWED: The temporary API mutation authorization for the completed browser run has ended. This current state supersedes any earlier [USER OVERRIDE — API MUTATIONS ALLOWED] note. Do not plan or call POST/PUT/PATCH/DELETE requests through fetch_url or research_url unless the user explicitly enables /allow-api for this conversation. Continue through the visible UI or ask for /allow-api.]', + 'api_authorization_state', + )); + this._persist(tabId); + } + this.apiAllowedInjected.delete(tabId); + } + } + setAlwaysAllowApiMutations(allowed) { const nextAllowed = allowed === true; const revoked = this.alwaysAllowApiMutations && !nextAllowed; @@ -7234,7 +7259,7 @@ export class Agent extends LoopDetector { // system prompt cannot retract it. Append the live state only for chats // that actually saw that note and lost their effective authorization. for (const tabId of [...this.apiAllowedInjected]) { - if (this.apiAllowedTabs.has(tabId)) continue; + if (this.isApiMutationsAllowed(tabId)) continue; const messages = this.conversations.get(tabId); if (Array.isArray(messages)) { messages.push(this._appOwnedUserMessage( @@ -7248,7 +7273,9 @@ export class Agent extends LoopDetector { } isApiMutationsAllowed(tabId) { - return this.alwaysAllowApiMutations || this.apiAllowedTabs.has(tabId); + return this.alwaysAllowApiMutations + || this.apiAllowedTabs.has(tabId) + || this.temporaryApiAllowedTabs.has(tabId); } // Browser-free loop detection is inherited from LoopDetector. These @@ -23987,6 +24014,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._compactCooldown.delete(tabId); this.hydratedTabs.delete(tabId); this.apiAllowedTabs.delete(tabId); + this.temporaryApiAllowedTabs.delete(tabId); this.apiAllowedInjected.delete(tabId); this._cleanupTab(tabId, { preserveRunGuard: true }); const t = this.persistTimers.get(tabId); diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index 48f1901ec..a128d478b 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -16,6 +16,8 @@ import { refreshBuiltInSkillRecord, } from './agent/skills.js'; import { ScheduledJobManager } from './agent/scheduler.js'; +import { cloudSafeScheduledJob, createCloudRunController } from './cloud-runs.js'; +import { createCloudBridge } from './cloud-bridge.js'; import { APOCALYPSE_DOWNLOAD_ALARM, APOCALYPSE_UPDATE_ALARM, createApocalypseController, sweepOpfsSwapFiles } from './agent/apocalypse-mode.js'; import { createEmergencyDownloadController } from './agent/emergency-download-controller.js'; import { createHostedOfflineRagIndexClient } from './agent/offline-rag-index-host.js'; @@ -175,7 +177,8 @@ const teacherSessionStore = createTeacherSessionStore(browser.storage.session); const teacherRunInterlock = createTeacherRunInterlock(teacherSessionStore, { automationOwnsTab: (tabId) => agent.isRunning(tabId) || detachedRunStarts.has(tabId) - || scheduler.isRunning(tabId), + || scheduler.isRunning(tabId) + || cloudRunController.isRunning(tabId), }); agent.setRunStartGuard((tabId) => teacherRunInterlock.guardRunStart(tabId)); const profileSync = new ProfileSyncManager(browser.storage.local); @@ -210,6 +213,20 @@ const scheduler = new ScheduledJobManager({ agent.setScheduler(scheduler); scheduler.start(); +// Firefox has no offscreen document, so the Cloud Bridge socket lives in this +// background page and hands commands straight to handleMessage. +const cloudBridge = createCloudBridge({ dispatch: (msg) => handleMessage(msg, null) }); +const cloudRunController = createCloudRunController({ + chromeApi: browser, + agent, + bridge: cloudBridge, + sendIndicator: (tabId, type) => sendIndicatorMessage(tabId, type), + workflowTrace, +}); +alwaysAllowApiMutationsReady + .then(() => cloudRunController.syncBridge()) + .catch(() => {}); + const MAX_AGENT_STEPS_DEFAULT = 130; const MAX_AGENT_STEPS_UNLIMITED_SENTINEL = 200; const CONTEXT_MENU_ASK_SELECTION_ID = 'webbrain-ask-selection'; @@ -1048,6 +1065,7 @@ browser.runtime.onInstalled.addListener(async (details) => { await loadClarifyTimeout(); await loadAutoScreenshot(); await syncAgentUserMemoryFromStorage().catch(() => {}); + await cloudRunController.syncBridge().catch(() => {}); scheduleUserMemoryExtractionDrain(5000); console.log('[WebBrain] Extension installed, providers loaded.'); }); @@ -1055,6 +1073,7 @@ browser.runtime.onInstalled.addListener(async (details) => { browser.runtime.onStartup?.addListener?.(async () => { await createContextMenus(); syncAgentUserMemoryFromStorage().catch(() => {}); + cloudRunController.syncBridge().catch(() => {}); scheduleUserMemoryExtractionDrain(5000); }); @@ -1098,6 +1117,10 @@ browser.storage.onChanged.addListener((changes) => { const value = changes[API_MUTATION_OBSERVER_KEY].newValue; setApiMutationObserverEnabled(value === undefined || value === true); } + if (changes.webbrainCloudBridgeEnabled || changes.webbrainCloudBridgeUrl + || changes.webbrainCloudBridgeToken || changes.webbrainCloudBridgeBrowserId) { + cloudRunController.syncBridge().catch(() => {}); + } if (changes.strictSecretMode) { agent.strictSecretMode = changes.strictSecretMode.newValue === true; // Strict mode also appends a global system note after enabled skills, so @@ -3413,6 +3436,40 @@ async function handleMessage(msg, sender) { return { ok: true, enabled: msg.enabled }; } + case 'cloud_run': + return await cloudRunController.startRun(msg); + case 'cloud_workflow_compile': + return await cloudRunController.compileWorkflow(msg); + case 'cloud_workflow_run': + return await cloudRunController.startWorkflowRun(msg); + case 'cloud_status': + return await cloudRunController.status(msg); + case 'cloud_scheduled_jobs': { + const jobIds = [...new Set((msg.jobIds || msg.job_ids || []) + .map(value => String(value || '').trim()) + .filter(Boolean))].slice(0, 100); + if (!jobIds.length) { + return { error: 'cloud_scheduled_jobs requires expected job IDs.', status: 400 }; + } + const expected = new Set(jobIds); + const jobs = await scheduler.listJobs({ tabId: null }); + return { + ok: true, + jobs: jobs + .filter(job => expected.has(String(job?.id || ''))) + .map(job => cloudSafeScheduledJob(job, { strictSecretMode: agent.strictSecretMode === true })), + }; + } + case 'cloud_respond': + return await cloudRunController.respond(msg); + case 'cloud_abort': + return await cloudRunController.abort(msg); + case 'cloud_bridge_start': + return await cloudRunController.startBridge(msg.url); + case 'cloud_bridge_stop': + return await cloudRunController.stopBridge(); + case 'cloud_bridge_status': + return await cloudRunController.bridgeStatus(); case 'get_providers': { return { providers: providerManager.getAll(), active: providerManager.activeProviderId }; } diff --git a/src/firefox/src/cloud-bridge.js b/src/firefox/src/cloud-bridge.js new file mode 100644 index 000000000..01d0ba3a7 --- /dev/null +++ b/src/firefox/src/cloud-bridge.js @@ -0,0 +1,235 @@ +/** + * Outbound WebSocket bridge for MCP and other controllers (Firefox). + * + * Mirror of src/chrome/src/offscreen/cloud-bridge.js. Firefox MV2 has a + * persistent background page and no offscreen documents, so the socket lives + * in the background page and commands are handed to `dispatch` directly + * instead of through runtime.sendMessage (a page does not receive its own + * runtime messages). Keep the protocol identical to the Chrome file. + */ + +export const BRIDGE_PROTOCOL_VERSION = 2; +const BRIDGE_CAPABILITIES = ['saved_workflows_v1', 'run_modes_v1', 'scheduled_jobs_v1']; +const ALLOWED_BRIDGE_ACTIONS = new Set([ + 'cloud_run', + 'cloud_workflow_compile', + 'cloud_workflow_run', + 'cloud_status', + 'cloud_scheduled_jobs', + 'cloud_respond', + 'cloud_abort', +]); + +export function createCloudBridge({ dispatch, WebSocketImpl = globalThis.WebSocket, nav = globalThis.navigator } = {}) { + let socket = null; + let bridgeUrl = null; + let enabled = false; + let reconnectTimer = null; + let reconnectAttempt = 0; + let lastError = ''; + // With a token configured the backend must approve each new socket before + // any cloud_* command runs; without one the legacy local behaviour is kept. + let identity = { token: '', browserId: '', installationId: '', extensionVersion: '' }; + let approval = 'not_required'; // not_required | pending | approved | rejected + + function browserInfo() { + const match = /(Edg|Firefox|Chrome)\/([\d.]+)/.exec(nav?.userAgent || ''); + const names = { Edg: 'Edge', Firefox: 'Firefox', Chrome: 'Chrome' }; + return { name: match ? names[match[1]] : 'unknown', version: match ? match[2] : '' }; + } + + function normalizeBridgeUrl(value) { + const url = new URL(String(value || 'ws://127.0.0.1:17374/extension')); + const host = url.hostname.toLowerCase(); + if (url.protocol !== 'ws:' || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(host)) { + throw new Error('MCP URL must use ws:// on localhost.'); + } + return url.href; + } + + function status() { + return { + enabled, + url: bridgeUrl, + browserId: identity.browserId || null, + installationId: identity.installationId || null, + approval, + connected: socket?.readyState === WebSocketImpl.OPEN, + readyState: socket ? socket.readyState : null, + reconnectAttempt, + lastError, + }; + } + + function sendJson(obj, target = socket) { + if (!target || target.readyState !== WebSocketImpl.OPEN) return; + try { + target.send(JSON.stringify(obj)); + } catch (e) { + lastError = e.message || String(e); + } + } + + function scheduleReconnect() { + if (!enabled || !bridgeUrl || reconnectTimer) return; + const delay = Math.min(30000, 500 * Math.pow(2, reconnectAttempt++)); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); + } + + function connect() { + if (!enabled || !bridgeUrl) return; + if (socket && (socket.readyState === WebSocketImpl.OPEN || socket.readyState === WebSocketImpl.CONNECTING)) return; + try { + const nextSocket = new WebSocketImpl(bridgeUrl); + socket = nextSocket; + nextSocket.addEventListener('open', () => { + if (socket !== nextSocket) return; + reconnectAttempt = 0; + lastError = ''; + // Every new socket starts unapproved; approval never carries over. + approval = identity.token ? 'pending' : 'not_required'; + const hello = { + type: 'hello', + client: 'webbrain-extension', + protocolVersion: BRIDGE_PROTOCOL_VERSION, + capabilities: BRIDGE_CAPABILITIES, + browser: browserInfo(), + extensionVersion: identity.extensionVersion, + platform: nav?.platform || '', + status: status(), + }; + if (identity.token) { + hello.auth = { type: 'bearer', token: identity.token }; + hello.browserId = identity.browserId; + hello.installationId = identity.installationId; + } + sendJson(hello, nextSocket); + }); + nextSocket.addEventListener('message', async (event) => { + if (socket !== nextSocket) return; + let msg; + try { + msg = JSON.parse(event.data); + } catch (e) { + sendJson({ ok: false, error: `Invalid JSON message: ${e.message}` }, nextSocket); + return; + } + + if (msg.type === 'connection_pending' || msg.type === 'connection_approved' || msg.type === 'connection_rejected') { + if (!identity.token) return; + if (msg.browserId && msg.browserId !== identity.browserId) return; + if (msg.type === 'connection_pending') { + if (approval !== 'approved') approval = 'pending'; + } else if (msg.type === 'connection_approved') { + approval = 'approved'; + lastError = ''; + } else { + approval = 'rejected'; + lastError = String(msg.reason || 'Connection rejected by backend'); + // A backoff timer from an earlier socket must not reconnect a rejected browser. + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; + try { nextSocket.close(); } catch {} + } + return; + } + + const id = msg.id || null; + const action = msg.action || msg.command; + const payload = msg.payload || msg; + if (!action) { + sendJson({ id, ok: false, error: 'Missing action' }, nextSocket); + return; + } + if (!ALLOWED_BRIDGE_ACTIONS.has(action)) { + sendJson({ id, ok: false, error: `Unsupported cloud bridge action: ${action}` }, nextSocket); + return; + } + if (identity.token && approval !== 'approved') { + sendJson({ id, ok: false, error: 'Connection not approved', code: 'connection_not_approved', status: 403 }, nextSocket); + return; + } + + try { + const response = await dispatch({ ...payload, target: 'background', action }); + const isRunSnapshot = !!response + && (response.runId != null || response.run_id != null) + && typeof response.status === 'string'; + if (response?.error && !isRunSnapshot) { + sendJson({ id, ok: false, error: response.error, status: response.status || 500 }, nextSocket); + } else { + sendJson({ id, ok: true, result: response }, nextSocket); + } + } catch (e) { + sendJson({ id, ok: false, error: e.message || String(e) }, nextSocket); + } + }); + nextSocket.addEventListener('close', () => { + if (socket !== nextSocket) return; + socket = null; + // A rejected browser stays rejected until the settings change. + if (approval === 'rejected') return; + approval = identity.token ? 'pending' : 'not_required'; + scheduleReconnect(); + }); + nextSocket.addEventListener('error', () => { + if (socket !== nextSocket) return; + lastError = 'WebSocket error'; + }); + } catch (e) { + lastError = e.message || String(e); + socket = null; + scheduleReconnect(); + } + } + + function start(msg = {}) { + let nextUrl; + try { + nextUrl = normalizeBridgeUrl(msg.url || bridgeUrl); + } catch (error) { + lastError = error.message || String(error); + return { ...status(), error: lastError }; + } + const nextIdentity = { + token: String(msg.token || ''), + browserId: String(msg.browserId || ''), + installationId: String(msg.installationId || ''), + extensionVersion: String(msg.extensionVersion || ''), + }; + const identityChanged = JSON.stringify(nextIdentity) !== JSON.stringify(identity); + const changed = (bridgeUrl && bridgeUrl !== nextUrl) || identityChanged; + enabled = true; + bridgeUrl = nextUrl; + identity = nextIdentity; + if (identityChanged) { + approval = identity.token ? 'pending' : 'not_required'; + reconnectAttempt = 0; + } + if (changed && socket) { + const previousSocket = socket; + socket = null; + try { previousSocket.close(); } catch {} + } + connect(); + return status(); + } + + function stop() { + enabled = false; + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; + reconnectAttempt = 0; + if (socket) { + const previousSocket = socket; + socket = null; + try { previousSocket.close(); } catch {} + } + return status(); + } + + return { start, stop, status }; +} diff --git a/src/firefox/src/cloud-runs.js b/src/firefox/src/cloud-runs.js new file mode 100644 index 000000000..f4a08e83f --- /dev/null +++ b/src/firefox/src/cloud-runs.js @@ -0,0 +1,1466 @@ +import { + compileSuccessfulWorkflowByRunId, + finalizeSavedWorkflowDraft, + normalizeSavedWorkflow, +} from './agent/workflows.js'; +import { isCredentialField } from './agent/credential-fields.js'; + +const DEFAULT_CLOUD_BRIDGE_URL = 'ws://127.0.0.1:17374/extension'; +const CLOUD_RUN_STORAGE_KEY = 'webbrainCloudRunSnapshots'; +const CLOUD_UPDATE_LIMIT = 200; +const CLOUD_RUN_LIMIT = 50; +const CLOUD_STRING_LIMIT = 16 * 1024; +const CLOUD_RUN_PERSIST_BYTES_LIMIT = 256 * 1024; +const CLOUD_PERSIST_BYTES_LIMIT = 4 * 1024 * 1024; +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'aborted']); +// Suffix match on normalized keys (non-alnum stripped). Avoid bare `pin` as a +// suffix — it over-matches `spin`, `mapPin`, etc. Short exact keys live in the set. +const SENSITIVE_CLOUD_KEY = /(?:authorization|cookie|password|passwd|passphrase|passcode|pincode|(?:verification|confirmation|security|auth|email|twofactor|2fa|mfa|onetime|recovery)code|secret|credential|privatekey|apikey|token|accesskeyid|secretaccesskey)$/i; +const SENSITIVE_CLOUD_KEY_EXACT = new Set(['code', 'pin', 'otp', 'cvv', 'cvc', 'ssn']); +const LARGE_IMAGE_KEY = /(?:attachimage|screenshot|image|imagedata|dataurl)$/i; +const CLOUD_TEXT_ENTRY_TOOLS = new Set(['set_field', 'type_ax', 'type_text', 'iframe_type']); +const VERIFY_FORM_VALUE_KEYS = new Set(['value', 'controlvalue', 'valueprefix', 'valuesuffix']); +// Strict-secret mode is deny-by-default: an update leaves the browser carrying +// only the evidence a cloud caller needs to score the run. A per-tool allowlist +// is the wrong shape here — a secret typed into a visible field comes straight +// back out of the next page read, and model prose repeats it in plain text. +// These are the narrow carve-outs that survive, keyed by what grading reads. +const CLOUD_STRICT_MODEL_TEXT_TYPES = new Set(['text', 'text_delta']); +// `error` and `run_status` carry the model's final response as `message`. +const CLOUD_STRICT_DROPPED_MESSAGE_TYPES = new Set(['error', 'run_status']); +// done_json and verify_form keep their shape through dedicated value-level +// redactors below; every other tool result is reduced to a bare envelope. +const CLOUD_STRICT_STRUCTURED_RESULT_TOOLS = new Set(['done_json', 'verify_form']); +// Scalar, non-page-derived argument keys. `fetch_url` is handled separately so +// its URL is reduced to origin-only evidence rather than dropped. +const CLOUD_STRICT_ARG_EVIDENCE_KEYS = new Set(['skill_id', 'method', 'url_origin']); +// A caller has to read a clarification to answer it, so `clarify` cannot be +// blanked the way model prose is. Prompt instructions are not a redaction +// boundary either, so strict runs additionally redact by *value*: every secret +// this run typed into a page is remembered and struck from the text of every +// later update, clarifications included. That leaves a usable question while +// removing the literal the key-based scrubber cannot recognize. +const CLOUD_STRICT_SECRET_VALUE_LIMIT = 256; +// Short values would mangle ordinary prose on a coincidental substring match. +// Numeric PIN/CVV fragments are still security-sensitive, so they are tracked +// with boundary-aware replacement below. +const CLOUD_STRICT_SECRET_MIN_LENGTH = 4; +const WORKFLOW_PARAMETER_VALUE_LIMIT = 10_000; +const SENSITIVE_URL_COMPONENT_KEY = /(?:^|[-_.\s])(?:auth(?:orization)?|api[-_.\s]?key|key|token|secret|signature|sig|code|credential|password|passcode|otp)(?:$|[-_.\s])/i; +const SENSITIVE_URL_PATH_LABELS = new Set([ + 'auth', 'authorization', 'apikey', 'key', 'token', 'accesstoken', 'refreshtoken', + 'secret', 'signature', 'sig', 'code', 'authcode', 'verificationcode', + 'credential', 'password', 'passcode', 'otp', 'downloadkey', 'sharetoken', +]); + +function cloudRunError(message, status) { + return Object.assign(new Error(message), { status }); +} + +export function normalizeCloudRunMode(value, fallback = 'act') { + const mode = String(value ?? '').trim().toLowerCase(); + if (!mode) return fallback; + if (!['ask', 'act'].includes(mode)) { + throw cloudRunError('Cloud run `mode` must be `ask` or `act`.', 400); + } + return mode; +} + +function normalizedCloudKey(key) { + return String(key || '').replace(/[^a-z0-9]/gi, ''); +} + +export function normalizeCloudBridgeUrl(value = DEFAULT_CLOUD_BRIDGE_URL) { + const url = new URL(String(value || DEFAULT_CLOUD_BRIDGE_URL)); + const host = url.hostname.toLowerCase(); + // WHATWG URL keeps the brackets on IPv6 literals: ws://[::1]/… parses to + // hostname "[::1]", so both spellings must be allowlisted (same as + // LOCAL_OLLAMA_HOSTS in ollama-handoff.js). + if (url.protocol !== 'ws:' || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(host)) { + throw new Error('WebBrain cloud bridge URL must use ws:// on localhost.'); + } + return url.href; +} + +export function isSensitiveCloudKey(key) { + const normalizedKey = normalizedCloudKey(key); + if (!normalizedKey) return false; + return SENSITIVE_CLOUD_KEY.test(normalizedKey) || SENSITIVE_CLOUD_KEY_EXACT.has(normalizedKey); +} + +function hasCloudOutputSchema(value) { + return value !== null && value !== undefined; +} + +function scrubCloudValue(value) { + try { + return JSON.parse(JSON.stringify(value, (key, item) => { + const normalizedKey = normalizedCloudKey(key); + if (normalizedKey && isSensitiveCloudKey(key)) { + return '[redacted]'; + } + if (typeof item === 'string' && /^data:image\//i.test(item)) { + return `[image omitted: ${item.length} chars]`; + } + if (LARGE_IMAGE_KEY.test(normalizedKey) && typeof item === 'string' && item.length > 500) { + return `[large payload omitted: ${item.length} chars]`; + } + if (typeof item === 'string' && item.length > CLOUD_STRING_LIMIT) { + return `${item.slice(0, CLOUD_STRING_LIMIT)}\n[truncated ${item.length - CLOUD_STRING_LIMIT} chars for cloud persistence]`; + } + return item; + })); + } catch { + return { unserializable: true }; + } +} + +function cloudUrlEvidence(value) { + try { + const url = new URL(String(value || '')); + if (!['http:', 'https:'].includes(url.protocol)) return {}; + return { url_origin: url.origin }; + } catch { + return {}; + } +} + +function cloudTerminalUrl(value, { strictSecretMode = false } = {}) { + if (!strictSecretMode) return value; + return cloudUrlEvidence(value).url_origin || ''; +} + +function redactVerifyFormValues(value) { + try { + return JSON.parse(JSON.stringify(value, (key, item) => ( + VERIFY_FORM_VALUE_KEYS.has(normalizedCloudKey(key).toLowerCase()) + ? '[redacted form value]' + : item + ))); + } catch { + return { success: false, sensitivePayloadRedacted: true }; + } +} + +// Every leaf a secret can be encoded in goes, not just strings: a six-digit OTP +// serialized as `verification_code: 481920` is a number, and no key pattern +// recognizes that field name. Booleans and null survive because neither can +// carry a credential — which is also what makes a strict structured run +// gradable, since a `true` outcome flag comes through intact. A strict run's +// structured output is therefore assertable on booleans only. +function redactStrictStructuredValues(value) { + try { + return JSON.parse(JSON.stringify(value, (_key, item) => { + if (typeof item === 'string') return '[redacted strict value]'; + if (typeof item === 'number' || typeof item === 'bigint') return '[redacted strict number]'; + return item; + })); + } catch { + return { sensitivePayloadRedacted: true }; + } +} + +function cloudSafeUpdateData(type, data, { strictSecretMode = false } = {}) { + if (!data || typeof data !== 'object') return data; + const name = String(data.name || data.tool || ''); + if (strictSecretMode && CLOUD_STRICT_MODEL_TEXT_TYPES.has(type)) { + return { + ...data, + ...(Object.hasOwn(data, 'content') ? { content: '[redacted strict text]' } : {}), + }; + } + if (strictSecretMode && CLOUD_STRICT_DROPPED_MESSAGE_TYPES.has(type)) { + // Dropped rather than replaced so the run-level fallbacks below still pick + // their own descriptive constant instead of storing a placeholder. + const { message: _message, ...rest } = data; + return rest; + } + if (!strictSecretMode && type === 'tool_call' && CLOUD_TEXT_ENTRY_TOOLS.has(name)) { + const args = data.args && typeof data.args === 'object' ? data.args : {}; + return { + ...data, + args: { + ...args, + ...(Object.hasOwn(args, 'text') ? { text: '[redacted typed text]' } : {}), + ...(Object.hasOwn(args, 'value') ? { value: '[redacted typed text]' } : {}), + }, + }; + } + if (strictSecretMode && type === 'tool_call' && name === 'fetch_url') { + const args = data.args && typeof data.args === 'object' ? data.args : {}; + const method = String(args.method || '').toUpperCase(); + return { + ...data, + args: { + ...(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'].includes(method) ? { method } : {}), + ...cloudUrlEvidence(args.url), + ...(Object.hasOwn(args, 'body') ? { body: '[redacted request body]' } : {}), + }, + }; + } + if (strictSecretMode && type === 'tool_result' && name === 'fetch_url') { + const result = data.result && typeof data.result === 'object' ? data.result : {}; + return { + ...data, + result: { + success: result.success === true, + ...(Number.isFinite(Number(result.status)) ? { status: Number(result.status) } : {}), + sensitivePayloadRedacted: true, + }, + }; + } + if (strictSecretMode && type === 'tool_result' && name === 'verify_form') { + return { + ...data, + result: redactVerifyFormValues(data.result), + }; + } + if (strictSecretMode && type === 'tool_call' && name === 'done_json') { + const args = data.args && typeof data.args === 'object' ? data.args : {}; + return { + ...data, + args: { + ...args, + ...(Object.hasOwn(args, 'result') ? { result: redactStrictStructuredValues(args.result) } : {}), + ...(Object.hasOwn(args, 'summary') ? { summary: '[redacted strict summary]' } : {}), + }, + }; + } + if (strictSecretMode && type === 'tool_result' && name === 'done_json') { + const result = data.result && typeof data.result === 'object' ? data.result : {}; + return { + ...data, + result: { + ...result, + ...(Object.hasOwn(result, 'result') ? { result: redactStrictStructuredValues(result.result) } : {}), + ...(Object.hasOwn(result, 'cloudResult') ? { cloudResult: redactStrictStructuredValues(result.cloudResult) } : {}), + ...(Object.hasOwn(result, 'invalidResult') ? { invalidResult: redactStrictStructuredValues(result.invalidResult) } : {}), + ...(Object.hasOwn(result, 'summary') ? { summary: '[redacted strict summary]' } : {}), + }, + }; + } + if (strictSecretMode && type === 'tool_call') { + const args = data.args && typeof data.args === 'object' ? data.args : {}; + const evidence = Object.fromEntries(Object.entries(args).filter(([key, value]) => ( + CLOUD_STRICT_ARG_EVIDENCE_KEYS.has(key) && (typeof value !== 'object' || value === null) + ))); + return { + ...data, + args: { ...evidence, sensitiveArgsRedacted: true }, + }; + } + if (strictSecretMode && type === 'tool_result' && !CLOUD_STRICT_STRUCTURED_RESULT_TOOLS.has(name)) { + // Any read tool echoes back whatever was typed into the page, so success + // and HTTP status are all a result may carry out of a strict run. + const result = data.result && typeof data.result === 'object' ? data.result : {}; + return { + ...data, + result: { + success: result.success === true, + ...(Number.isFinite(Number(result.status)) ? { status: Number(result.status) } : {}), + sensitivePayloadRedacted: true, + }, + }; + } + return data; +} + +// Walks a raw tool result for string or numeric values sitting under a key the +// scrubber already treats as sensitive. Depth-bounded: a page read can be +// enormous, and this runs on every update. +function collectSensitiveStrings(value, into, depth = 0, sensitiveParent = false) { + if (depth > 6 || into.length > CLOUD_STRICT_SECRET_VALUE_LIMIT) return; + if (typeof value === 'string') { + if (sensitiveParent) into.push(value); + return; + } + if (typeof value === 'number') { + if (sensitiveParent && Number.isFinite(value)) into.push(String(value)); + return; + } + if (Array.isArray(value)) { + for (const item of value) collectSensitiveStrings(item, into, depth + 1, sensitiveParent); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, item] of Object.entries(value)) { + collectSensitiveStrings( + item, + into, + depth + 1, + sensitiveParent || isSensitiveCloudKey(key), + ); + if (into.length > CLOUD_STRICT_SECRET_VALUE_LIMIT) return; + } +} + +function isSensitiveUrlComponentKey(value) { + const text = String(value || ''); + return isSensitiveCloudKey(text) + || SENSITIVE_URL_COMPONENT_KEY.test(text) + || /(?:Key|Code|Signature|Token|Secret|Password)$/.test(text); +} + +function isSensitiveUrlPathLabel(value) { + return SENSITIVE_URL_PATH_LABELS.has(normalizedCloudKey(value).toLowerCase()); +} + +// URL paths and queries are withheld wholesale from strict trace evidence, but +// the value registry must be narrower: registering `/profile` or `?tab=activity` +// as a secret corrupts legitimate caller-visible results. Remember userinfo, +// values under credential-like query keys, and path/hash components that either +// carry credential vocabulary themselves or follow an explicit credential label. +function collectUrlSecretStrings(value, into) { + let url; + try { + url = new URL(String(value || '')); + } catch { + return; + } + if (!['http:', 'https:'].includes(url.protocol)) return; + const add = (candidate) => { + const text = String(candidate || ''); + if (text && into.length <= CLOUD_STRICT_SECRET_VALUE_LIMIT) into.push(text); + }; + const addEncoded = (candidate) => { + add(candidate); + try { + add(decodeURIComponent(String(candidate || ''))); + } catch {} + }; + addEncoded(url.username); + addEncoded(url.password); + let followsSensitivePathLabel = false; + for (const segment of url.pathname.split('/').filter(Boolean)) { + let decoded = segment; + try { decoded = decodeURIComponent(segment); } catch {} + const isLabel = isSensitiveUrlPathLabel(decoded); + if (followsSensitivePathLabel || (!isLabel && isSensitiveUrlComponentKey(decoded))) { + addEncoded(segment); + } + followsSensitivePathLabel = isLabel; + } + for (const [key, item] of url.searchParams) { + if (isSensitiveUrlComponentKey(key)) addEncoded(item); + } + const hash = url.hash.replace(/^#/, ''); + if (hash) { + let decodedHash = hash; + try { decodedHash = decodeURIComponent(hash); } catch {} + let foundSensitiveHashParam = false; + if (decodedHash.includes('=')) { + const hashParams = new URLSearchParams(decodedHash.replace(/^\?/, '')); + for (const [key, item] of hashParams) { + if (!isSensitiveUrlComponentKey(key)) continue; + foundSensitiveHashParam = true; + addEncoded(item); + } + } + if (!foundSensitiveHashParam + && !isSensitiveUrlPathLabel(decodedHash) + && isSensitiveUrlComponentKey(decodedHash)) { + addEncoded(hash); + } + } +} + +// URL credentials are not unique to fetch_url: navigate, new_tab, read tools, +// downloads, custom skills, and tool results can all carry URL-shaped fields. +// Walk only fields whose normalized name ends in url/urls, but follow arrays +// and nested containers below such a field so credential-like components in +// every concrete http(s) value are registered before later prose is published. +function collectUrlSecretsFromNamedFields(value, into, depth = 0, urlBearing = false) { + if (depth > 6 || into.length > CLOUD_STRICT_SECRET_VALUE_LIMIT) return; + if (typeof value === 'string') { + if (urlBearing) collectUrlSecretStrings(value, into); + return; + } + if (Array.isArray(value)) { + for (const item of value) collectUrlSecretsFromNamedFields(item, into, depth + 1, urlBearing); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, item] of Object.entries(value)) { + const normalizedKey = normalizedCloudKey(key).toLowerCase(); + collectUrlSecretsFromNamedFields( + item, + into, + depth + 1, + urlBearing || /urls?$/.test(normalizedKey), + ); + if (into.length > CLOUD_STRICT_SECRET_VALUE_LIMIT) return; + } +} + +// Full URLs are trace-only sensitive in strict mode even when their individual +// path/query components are ordinary public data. Keep this registry separate +// from credential values so a repeated URL is removed from prose updates while +// a schema-valid result such as `{ section: 'profile' }` stays intact. +function collectUrlTraceStringsFromNamedFields(value, into, depth = 0, urlBearing = false) { + if (depth > 6 || into.length > CLOUD_STRICT_SECRET_VALUE_LIMIT) return; + if (typeof value === 'string') { + if (!urlBearing) return; + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol)) return; + into.push(value); + if (url.href !== value) into.push(url.href); + } catch {} + return; + } + if (Array.isArray(value)) { + for (const item of value) collectUrlTraceStringsFromNamedFields(item, into, depth + 1, urlBearing); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, item] of Object.entries(value)) { + const normalizedKey = normalizedCloudKey(key).toLowerCase(); + collectUrlTraceStringsFromNamedFields( + item, + into, + depth + 1, + urlBearing || /urls?$/.test(normalizedKey), + ); + if (into.length > CLOUD_STRICT_SECRET_VALUE_LIMIT) return; + } +} + +// verify_form represents an input's identity and value as siblings, for +// example `{ name: 'api_key', value: 'secret' }`. The generic key walk above +// cannot connect those fields, so reuse the same credential detector that +// classifies fields when they are filled and register only that field record's +// value-bearing leaves. +function collectCredentialFieldValues(value, into, depth = 0) { + if (depth > 6 || into.length > CLOUD_STRICT_SECRET_VALUE_LIMIT) return; + if (Array.isArray(value)) { + for (const item of value) collectCredentialFieldValues(item, into, depth + 1); + return; + } + if (!value || typeof value !== 'object') return; + const credential = isCredentialField({ + type: value.type, + name: value.name, + id: value.id, + autocomplete: value.autocomplete, + ariaLabel: value.ariaLabel ?? value.aria_label, + placeholder: value.placeholder, + labelText: value.labelText ?? value.label, + }).sensitive; + if (credential) { + for (const [key, item] of Object.entries(value)) { + if (!VERIFY_FORM_VALUE_KEYS.has(normalizedCloudKey(key).toLowerCase())) continue; + if (typeof item === 'string') into.push(item); + else if (typeof item === 'number' && Number.isFinite(item)) into.push(String(item)); + } + } + for (const item of Object.values(value)) { + if (item && typeof item === 'object') collectCredentialFieldValues(item, into, depth + 1); + if (into.length > CLOUD_STRICT_SECRET_VALUE_LIMIT) return; + } +} + +// A request body is a string argument, so the key-walk above cannot see into +// it. Both encodings a mutating call realistically uses are cheap to parse. +function collectRequestBodySecrets(body, into) { + if (typeof body !== 'string' || !body) return false; + // Do not silently skip a body that is too large for bounded inspection. The + // caller marks the run as overflowed so every later scalar is redacted rather + // than relying on the model not to repeat an unknown credential. + if (body.length > WORKFLOW_PARAMETER_VALUE_LIMIT) return true; + try { + collectSensitiveStrings(JSON.parse(body), into); + return false; + } catch { + // Not JSON — fall through to the form encoding. + } + try { + for (const [key, value] of new URLSearchParams(body)) { + if (isSensitiveCloudKey(key)) into.push(value); + } + } catch { + // Undecodable body: nothing to register, and the argument is dropped anyway. + } + return false; +} + +function redactWorkflowRuntimeValues(value, runtimeValues = [], label = '[workflow parameter]') { + if (value == null) return value; + const variants = new Set(); + for (const parameterValue of runtimeValues) { + if (typeof parameterValue !== 'string' || !parameterValue.length) continue; + variants.add(parameterValue); + try { + const componentEncoded = encodeURIComponent(parameterValue); + variants.add(componentEncoded); + variants.add(componentEncoded.replace(/%20/g, '+')); + variants.add(encodeURI(parameterValue)); + const formEncoded = new URLSearchParams([['value', parameterValue]]) + .toString() + .slice('value='.length); + variants.add(formEncoded); + } catch { + // Raw replacement above still protects malformed strings that URI + // encoders cannot represent (for example, lone UTF-16 surrogates). + } + } + const values = [...variants] + .sort((a, b) => b.length - a.length); + if (!values.length) return value; + const normalizePercentEscapes = input => input.replace( + /%[0-9a-f]{2}/gi, + match => match.toUpperCase(), + ); + const redactString = (input) => values.reduce((text, parameterValue) => { + const normalizedText = normalizePercentEscapes(text); + const normalizedValue = normalizePercentEscapes(parameterValue); + if (!normalizedText.includes(normalizedValue)) return text; + let result = ''; + let offset = 0; + let index = normalizedText.indexOf(normalizedValue, offset); + while (index !== -1) { + const boundaryMatch = parameterValue.length >= CLOUD_STRICT_SECRET_MIN_LENGTH + || ( + (index === 0 || !/[A-Za-z0-9]/.test(normalizedText[index - 1])) + && ( + index + normalizedValue.length === normalizedText.length + || !/[A-Za-z0-9]/.test(normalizedText[index + normalizedValue.length]) + ) + ); + if (!boundaryMatch) { + const nextOffset = index + normalizedValue.length; + result += text.slice(offset, nextOffset); + offset = nextOffset; + index = normalizedText.indexOf(normalizedValue, offset); + continue; + } + result += `${text.slice(offset, index)}${label}`; + offset = index + parameterValue.length; + index = normalizedText.indexOf(normalizedValue, offset); + } + return result + text.slice(offset); + }, input); + try { + return JSON.parse(JSON.stringify(value, (_key, item) => ( + // A secret typed as text can come back as a JSON number — a six-digit OTP + // is the obvious case. Match on the number's exact string form: a + // substring rule would strike an unrelated `3` out of `481920`. + typeof item === 'number' && values.includes(String(item)) + ? label + : (typeof item === 'string' ? redactString(item) : item) + ))); + } catch { + return { unserializable: true }; + } +} + +function serializedBytes(value) { + return new TextEncoder().encode(JSON.stringify(value)).length; +} + +function compactCloudRunForPersistence(run) { + const row = scrubCloudValue(run); + row.structured = row.structured ?? hasCloudOutputSchema(run?.outputSchema); + if (serializedBytes(row) <= CLOUD_RUN_PERSIST_BYTES_LIMIT) return row; + + const omittedUpdates = Array.isArray(row.updates) ? row.updates.length : 0; + row.updates = []; + row.persistenceTruncated = { omittedUpdates }; + if (serializedBytes(row) <= CLOUD_RUN_PERSIST_BYTES_LIMIT) return row; + + row.content = ''; + row.outputSchema = null; + row.persistenceTruncated.omittedContent = true; + row.persistenceTruncated.omittedSchema = true; + if (serializedBytes(row) <= CLOUD_RUN_PERSIST_BYTES_LIMIT) return row; + + delete row.result; + row.persistenceTruncated.omittedResult = true; + if (serializedBytes(row) <= CLOUD_RUN_PERSIST_BYTES_LIMIT) return row; + + return scrubCloudValue({ + runId: run?.runId, + status: run?.status, + workflowId: run?.workflowId || null, + traceRunId: run?.traceRunId || null, + parentRunId: run?.parentRunId || null, + mode: run?.mode || 'act', + captchaDiagnostics: run?.captchaDiagnostics || null, + tabId: run?.tabId, + task: run?.task, + structured: hasCloudOutputSchema(run?.outputSchema) || run?.structured === true, + pendingInput: run?.pendingInput || null, + summary: run?.summary, + content: '', + finalUrl: run?.finalUrl, + error: run?.error, + createdAt: run?.createdAt, + updatedAt: run?.updatedAt, + completedAt: run?.completedAt, + updates: [], + persistenceTruncated: { omittedUpdates, omittedResult: true, omittedSchema: true }, + }); +} + +/** + * Scheduled jobs answer a cloud query over the same bridge as run updates, so + * they need the same strict-secret treatment: a summarized job otherwise ships + * `lastResult`, `lastError`, `pendingClarify`, `target.url`, and + * `watch.lastObservation` — a child task's raw prompt and output — straight past + * every redaction applied to the parent run. Strict mode keeps only structural + * and enumerated fields; free text and URLs are dropped. + */ +export function cloudSafeScheduledJob(job, { strictSecretMode = false } = {}) { + if (!job || !strictSecretMode) return job; + return { + id: job.id, + kind: job.kind, + source: job.source || null, + status: job.status, + scheduledAt: job.scheduledAt, + nextRunAt: job.nextRunAt || job.scheduledAt, + lastOutcome: job.lastOutcome || null, + needsUserInput: job.needsUserInput === true, + clarificationRequired: job.clarificationRequired === true, + completedAt: job.completedAt || null, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + sensitiveFieldsRedacted: true, + }; +} + +export function buildCloudPersistenceRows(runs) { + const values = Array.isArray(runs) ? [...runs] : [...(runs?.values?.() || [])]; + const candidates = values + .sort((a, b) => String(b?.createdAt || '').localeCompare(String(a?.createdAt || ''))) + .slice(0, CLOUD_RUN_LIMIT) + .map(compactCloudRunForPersistence); + const rows = []; + let totalBytes = 2; + for (const row of candidates) { + const rowBytes = serializedBytes(row) + (rows.length ? 1 : 0); + if (totalBytes + rowBytes > CLOUD_PERSIST_BYTES_LIMIT) continue; + rows.push(row); + totalBytes += rowBytes; + } + return rows; +} + +function cloudSnapshot(run, { includeUpdates = true } = {}) { + if (!run) return null; + return { + runId: run.runId, + status: run.status, + workflowId: run.workflowId || null, + parentRunId: run.parentRunId || null, + mode: run.mode || 'act', + tabId: run.tabId, + task: run.task, + structured: run.structured ?? hasCloudOutputSchema(run.outputSchema), + pendingInput: run.pendingInput || null, + ...(run.captchaDiagnostics ? { captchaDiagnostics: run.captchaDiagnostics } : {}), + result: run.result, + persistenceTruncated: run.persistenceTruncated, + summary: run.summary, + content: run.content, + finalUrl: run.finalUrl, + error: run.error, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + completedAt: run.completedAt, + updates: includeUpdates ? run.updates : undefined, + }; +} + +function isUsableCloudTab(tab) { + if (tab?.id == null) return false; + try { + // Chrome can leave an unpacked-extension startup tab's `url` empty while + // exposing the loaded page through `pendingUrl`, even with status=complete. + const url = new URL(tab.url || tab.pendingUrl || ''); + return ['http:', 'https:', 'file:'].includes(url.protocol) || url.href === 'about:blank'; + } catch { + return false; + } +} + +export function createCloudRunController({ + chromeApi, + agent, + // Firefox MV2 has no offscreen document: the bridge runs in the background + // page (see cloud-bridge.js) and is injected here. + bridge, + sendIndicator = () => {}, + startRecording = null, + stopRecording = null, + workflowTrace = null, + now = () => new Date(), + makeRunId = () => `run_${globalThis.crypto.randomUUID()}`, +} = {}) { + const api = chromeApi; + const runs = new Map(); + const startingTabs = new Set(); + let hydratePromise = null; + let persistQueue = Promise.resolve(); + let persistTimer = null; + + const isoNow = () => now().toISOString(); + + async function persist() { + if (!api.storage?.session?.set) return; + const rows = buildCloudPersistenceRows(runs); + persistQueue = persistQueue + .catch(() => {}) + .then(() => api.storage.session.set({ [CLOUD_RUN_STORAGE_KEY]: rows })); + await persistQueue; + } + + function schedulePersist() { + if (persistTimer) return; + persistTimer = setTimeout(() => { + persistTimer = null; + persist().catch(() => {}); + }, 100); + } + + async function hydrate() { + if (hydratePromise) return hydratePromise; + hydratePromise = (async () => { + if (!api.storage?.session?.get) return; + const stored = await api.storage.session.get(CLOUD_RUN_STORAGE_KEY).catch(() => ({})); + const rows = Array.isArray(stored?.[CLOUD_RUN_STORAGE_KEY]) ? stored[CLOUD_RUN_STORAGE_KEY] : []; + let changed = false; + for (const row of rows) { + if (!row?.runId) continue; + const rawUpdates = Array.isArray(row.updates) ? row.updates : []; + let nextUpdateSeq = 0; + const updates = rawUpdates.map((update) => { + const candidate = Number(update?.seq); + const seq = Number.isSafeInteger(candidate) && candidate > nextUpdateSeq + ? candidate + : nextUpdateSeq + 1; + if (seq !== candidate) changed = true; + nextUpdateSeq = seq; + return { ...update, seq }; + }); + const restored = { ...row, updates, nextUpdateSeq }; + if (!TERMINAL_STATUSES.has(restored.status)) { + const at = isoNow(); + restored.status = restored.status === 'aborting' ? 'aborted' : 'failed'; + restored.pendingInput = null; + restored.error = restored.status === 'aborted' + ? 'Run aborted when the WebBrain service worker restarted.' + : 'Run interrupted when the WebBrain service worker restarted.'; + restored.updatedAt = at; + restored.completedAt = at; + changed = true; + } + runs.set(restored.runId, restored); + } + if (changed) await persist(); + })(); + return hydratePromise; + } + + async function activateTab(tab) { + if (!tab?.id) return tab; + await api.tabs.update(tab.id, { active: true }).catch(() => {}); + if (tab.windowId != null && api.windows?.update) { + await api.windows.update(tab.windowId, { focused: true }).catch(() => {}); + } + return tab; + } + + async function resolveTabId(requestedTabId) { + if (requestedTabId != null && requestedTabId !== '') { + const tab = await api.tabs.get(Number(requestedTabId)); + if (!isUsableCloudTab(tab)) throw new Error(`Tab ${requestedTabId} is not a controllable webpage.`); + return tab.id; + } + + const active = await api.tabs.query({ active: true, lastFocusedWindow: true }); + const activeTab = active.find(isUsableCloudTab); + if (activeTab) return activeTab.id; + + const allTabs = await api.tabs.query({}); + const fallback = allTabs.find(isUsableCloudTab); + if (fallback) return fallback.id; + + const created = await api.tabs.create({ url: 'about:blank', active: true }); + if (created?.id == null) throw new Error('Could not create a browser tab for the cloud run.'); + return created.id; + } + + async function getTabUrl(tabId) { + try { + return (await api.tabs.get(tabId))?.url || ''; + } catch { + return ''; + } + } + + // Kept out of the run object so a literal secret can never reach session + // storage or a persistence row. Dropped when the run leaves the map. + const strictSecretValues = new Map(); + const strictTraceValues = new Map(); + const strictSecretOverflowRuns = new Set(); + const strictTraceOverflowRuns = new Set(); + + function rememberStrictCandidates(run, candidates, registry, overflowRuns) { + if (!candidates.length) return; + const known = registry.get(run.runId) || []; + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue; + const shortNumericSecret = /^\d{1,3}$/.test(candidate); + if (candidate.length < CLOUD_STRICT_SECRET_MIN_LENGTH && !shortNumericSecret) continue; + if (candidate.length > WORKFLOW_PARAMETER_VALUE_LIMIT) continue; + if (known.includes(candidate)) continue; + if (known.length >= CLOUD_STRICT_SECRET_VALUE_LIMIT) { + // Never evict an earlier credential or trace-only URL and silently + // expose it later. Losing free-text utility is preferable to leakage. + overflowRuns.add(run.runId); + break; + } + known.push(candidate); + } + if (known.length) registry.set(run.runId, known); + } + + // Whatever a strict run types into a page is a value the caller must never + // read back, wherever it later resurfaces — a clarification question, a + // warning, a captcha diagnostic. Remember it here, before the redactors drop + // the argument that carried it. + function rememberStrictSecrets(run, type, data) { + const name = String(data?.name || data?.tool || ''); + const candidates = []; + const traceCandidates = []; + if (type === 'tool_call') { + const args = data?.args && typeof data.args === 'object' ? data.args : {}; + if (CLOUD_TEXT_ENTRY_TOOLS.has(name)) candidates.push(args.text, args.value); + collectUrlSecretsFromNamedFields(args, candidates); + collectUrlTraceStringsFromNamedFields(args, traceCandidates); + // A credential does not have to be typed into a page to exist. The + // disposable-signup scenario mints its account password inside the JSON + // body of `POST /accounts` and never types it, so registering only + // text-entry arguments left it unknown to the value redactor. + collectSensitiveStrings(args, candidates); + if (collectRequestBodySecrets(args.body, candidates)) { + strictSecretOverflowRuns.add(run.runId); + } + } else if (type === 'tool_result') { + // The value under a sensitive key is already known to be a secret; the + // key-based scrubber only masks it in place, so register the literal and + // strike it from later prose as well. + // + // Known limit: a secret the model only ever *reads* out of an + // unremarkable field — an OTP in the body text of an inbox message — is + // never seen here in a form this can recognize, so a clarification that + // quotes it is covered by the strict system prompt alone. Closing that + // would mean either blanking clarification text, which makes a strict run + // unanswerable, or entropy guessing at prose. + collectSensitiveStrings(data?.result, candidates); + collectUrlSecretsFromNamedFields(data?.result, candidates); + collectUrlTraceStringsFromNamedFields(data?.result, traceCandidates); + if (name === 'verify_form') collectCredentialFieldValues(data?.result, candidates); + } + rememberStrictCandidates(run, candidates, strictSecretValues, strictSecretOverflowRuns); + rememberStrictCandidates(run, traceCandidates, strictTraceValues, strictTraceOverflowRuns); + } + + function redactStrictSecretValues(run, value, strictSecretMode) { + if (!strictSecretMode) return value; + if (strictSecretOverflowRuns.has(run.runId)) return redactStrictStructuredValues(value); + const known = strictSecretValues.get(run.runId); + if (!known?.length) return value; + return redactWorkflowRuntimeValues(value, known, '[redacted strict value]'); + } + + function redactStrictTraceValues(run, value, strictSecretMode) { + const withoutSecrets = redactStrictSecretValues(run, value, strictSecretMode); + if (!strictSecretMode || strictSecretOverflowRuns.has(run.runId)) return withoutSecrets; + if (strictTraceOverflowRuns.has(run.runId)) return redactStrictStructuredValues(withoutSecrets); + const known = strictTraceValues.get(run.runId); + if (!known?.length) return withoutSecrets; + return redactWorkflowRuntimeValues(withoutSecrets, known, '[redacted strict URL]'); + } + + // Terminal prose reaches the caller by the same two routes as an update row, + // so it takes both secret and trace-only URL redaction. A structured + // `run.result` is handled separately below and takes only credential-value + // redaction so ordinary schema-valid URL components remain usable. + function redactStrictTerminal(run, value, strictSecretMode) { + return redactStrictTraceValues(run, value, strictSecretMode); + } + + function pushUpdate(run, type, data, runtimeValues = []) { + run.updatedAt = isoNow(); + const previous = run.updates.at(-1); + const strictSecretMode = agent.strictSecretMode === true; + if (strictSecretMode) rememberStrictSecrets(run, type, data); + // Consecutive text_delta events upsert the same seq: content grows in place + // and ts advances. Full-array pollers are fine; append-only / seq-cursor + // clients must re-read that row (or take a full snapshot) rather than + // assuming each seq is immutable. + if (type === 'text_delta' && previous?.type === 'text_delta') { + // Deltas must pass through the same redaction as any other update: this + // branch used to append raw model output straight onto the stored row. + const safeDelta = cloudSafeUpdateData(type, data, { strictSecretMode }); + previous.data = scrubCloudValue(redactStrictTraceValues(run, redactWorkflowRuntimeValues({ + ...previous.data, + content: strictSecretMode + ? (safeDelta?.content || '') + : `${previous.data?.content || ''}${safeDelta?.content || ''}`, + }, runtimeValues), strictSecretMode)); + previous.ts = run.updatedAt; + schedulePersist(); + return; + } + run.nextUpdateSeq = (Number(run.nextUpdateSeq) || 0) + 1; + const safeData = cloudSafeUpdateData(type, data, { strictSecretMode }); + const scrubbedData = scrubCloudValue( + redactStrictTraceValues(run, redactWorkflowRuntimeValues(safeData, runtimeValues), strictSecretMode), + ); + run.updates.push({ seq: run.nextUpdateSeq, type, data: scrubbedData, ts: run.updatedAt }); + if (run.updates.length > CLOUD_UPDATE_LIMIT) { + run.updates.splice(0, run.updates.length - CLOUD_UPDATE_LIMIT); + } + if (type === 'tool_result' && scrubbedData?.name === 'done_json') { + const result = data.result || {}; + const safeResult = scrubbedData.result || {}; + // Two different jobs, so two different redactors. The update row above is + // trace and persistence with no contract to honour, and takes the blunt + // leaf-type redaction. `run.result` and `run.summary` are the caller's + // answer — the schema they asked for — so they take value redaction: + // registered credentials are struck and everything else the contract + // declares survives. Redacting those by leaf type returned `completed` + // with every string and number replaced by a placeholder, which satisfies + // strict mode by making the run useless. + const publicSummary = strictSecretMode + ? redactStrictTraceValues(run, result.summary, true) + : safeResult.summary; + if (result.cloudFailed) { + run.status = 'failed'; + run.error = safeResult.error || 'done_json failed'; + run.summary = publicSummary || run.summary; + } else if (Object.prototype.hasOwnProperty.call(result, 'cloudResult')) { + run.result = strictSecretMode + ? redactStrictSecretValues(run, result.cloudResult, true) + : result.cloudResult; + run.summary = publicSummary || run.summary; + } + } + if (type === 'captcha_gate') { + // Keep the latest sanitized frame/vendor snapshot at run level so it + // survives the rolling 200-update window in exported cloud traces. + run.captchaDiagnostics = { ...scrubbedData, observedAt: run.updatedAt }; + } + if (type === 'clarify' && scrubbedData?.clarifyId && !TERMINAL_STATUSES.has(run.status)) { + run.status = 'needs_user_input'; + run.pendingInput = scrubbedData; + } + if (type === 'clarify_timeout_extended' && scrubbedData?.clarifyId + && (run.pendingInput?.clarifyId || run.pendingInput?.clarify_id) === scrubbedData.clarifyId + && run.status === 'needs_user_input' && Number(scrubbedData.deadlineTs) > 0) { + run.pendingInput = { ...run.pendingInput, deadlineTs: Number(scrubbedData.deadlineTs) }; + } + if (type === 'clarify_auto' && scrubbedData?.clarifyId + && (run.pendingInput?.clarifyId || run.pendingInput?.clarify_id) === scrubbedData.clarifyId + && run.status === 'needs_user_input') { + run.status = 'running'; + run.pendingInput = null; + } + if (type === 'run_status' + && ['clarification_required', 'captcha_manual_required'].includes(scrubbedData?.status) + && run.status !== 'aborting' + && run.status !== 'aborted') { + run.status = 'failed'; + run.error = scrubbedData.message + || (scrubbedData.status === 'captcha_manual_required' + ? 'Cloud run stopped because manual CAPTCHA completion is required.' + : 'Cloud run stopped because explicit clarification authorization is required.'); + run.pendingInput = null; + } + if (type === 'plan_review' && run.status === 'running') { + run.status = 'failed'; + run.error = 'Managed cloud runs cannot wait for interactive plan review.'; + agent.abort(run.tabId); + } + schedulePersist(); + } + + function validateWorkflowParameters(workflow, input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw cloudRunError('`parameters` must be an object.', 400); + } + const descriptors = new Map((workflow.parameters || []).map(parameter => [parameter.id, parameter])); + const parameters = Object.create(null); + for (const [id, value] of Object.entries(input)) { + if (!descriptors.has(id)) throw cloudRunError(`Unknown workflow parameter: ${id}`, 400); + if (typeof value !== 'string') throw cloudRunError(`Workflow parameter ${id} must be a string.`, 400); + if (value.length > WORKFLOW_PARAMETER_VALUE_LIMIT) { + throw cloudRunError(`Workflow parameter ${id} exceeds ${WORKFLOW_PARAMETER_VALUE_LIMIT} characters.`, 400); + } + parameters[id] = value; + } + for (const descriptor of descriptors.values()) { + if (descriptor.required !== false && !Object.hasOwn(parameters, descriptor.id)) { + throw cloudRunError(`Missing workflow parameter: ${descriptor.id}`, 400); + } + } + return parameters; + } + + async function startRun(msg = {}) { + await hydrate(); + const suppliedRunId = msg.runId ?? msg.run_id; + const requestedRunId = suppliedRunId == null ? '' : String(suppliedRunId).trim(); + const parentRunId = String(msg.parentRunId || msg.parent_run_id || '').trim() || null; + let parentRun = null; + let requestedTabId = msg.tabId ?? msg.tab_id; + if (parentRunId) { + parentRun = runs.get(parentRunId) || null; + if (parentRun) { + if (!TERMINAL_STATUSES.has(parentRun.status)) { + throw cloudRunError('Parent cloud run must be finished before it can be continued.', 409); + } + const existingChild = [...runs.values()].find(candidate => candidate.parentRunId === parentRunId); + if (existingChild) { + throw cloudRunError(`Cloud run has already been continued as ${existingChild.runId}.`, 409); + } + requestedTabId = parentRun.tabId; + } else if (requestedTabId == null || requestedTabId === '') { + throw cloudRunError('Parent cloud run is no longer available and has no saved tab.', 409); + } + } + const tabId = await resolveTabId(requestedTabId); + const workflow = msg._workflow || null; + const mode = workflow ? 'act' : normalizeCloudRunMode(msg.mode, parentRun?.mode || 'act'); + const workflowParameters = msg._workflowParameters || {}; + const workflowParameterValues = workflow ? Object.values(workflowParameters) : []; + const redactWorkflowValue = value => redactWorkflowRuntimeValues(value, workflowParameterValues); + const task = workflow + ? `Run saved workflow: ${workflow.name}` + : String(msg.task || msg.text || '').trim(); + if (!task) throw new Error('cloud_run requires `task`.'); + if (startingTabs.has(tabId) || agent.isRunning(tabId)) { + throw new Error(`Tab ${tabId} already has an active WebBrain run.`); + } + + const apiMutationsAllowed = msg.apiMutationsAllowed === true || msg.api_mutations_allowed === true; + if (apiMutationsAllowed && mode !== 'act') { + throw cloudRunError('API mutation permission requires cloud_run mode `act`.', 400); + } + const grantApiMutationsForRun = apiMutationsAllowed + && agent.isApiMutationsAllowed?.(tabId) !== true; + const outputSchema = workflow + ? null + : msg.outputSchema ?? msg.output_schema ?? msg.responseFormat?.schema ?? msg.response_format?.schema ?? null; + const structured = hasCloudOutputSchema(outputSchema); + const runId = requestedRunId || String(makeRunId()); + // Cloud-assigned IDs are valid correlation keys, but they must not replace + // an active or persisted run. Besides losing the older run from status + // queries, replacement aliases both runs' strict-secret registries; the + // first run to finish would then clear the second run's redaction state. + if (runs.has(runId)) { + throw cloudRunError(`Cloud run ${runId} already exists.`, 409); + } + startingTabs.add(tabId); + try { + await agent.assertRunStartAllowed?.(tabId, 'cloud', { cloudRun: true }); + const targetTab = await api.tabs.get(tabId); + await activateTab(targetTab); + } catch (error) { + startingTabs.delete(tabId); + throw error; + } + const createdAt = isoNow(); + const run = { + runId, + status: 'running', + workflowId: workflow?.id || null, + traceRunId: null, + parentRunId, + mode, + tabId, + task, + structured, + outputSchema, + capture: msg.capture === 'video' ? 'video' : 'none', + result: undefined, + summary: '', + content: '', + finalUrl: '', + error: '', + pendingInput: null, + updates: [], + nextUpdateSeq: 0, + createdAt, + updatedAt: createdAt, + completedAt: null, + }; + runs.set(run.runId, run); + try { + await persist(); + } catch (error) { + runs.delete(run.runId); + startingTabs.delete(tabId); + throw error; + } + + (async () => { + let recordingId = null; + const strictSecretMode = agent.strictSecretMode === true; + try { + // A continuation starts only after its parent has finished, so the + // agent's active-run map cannot identify the parent here. The cloud + // run record keeps the actual trace id; resolve its session from that + // trace instead of attaching the tab's potentially unrelated session. + const parentTraceRunId = parentRun?.traceRunId || null; + let parentTraceSessionId = null; + if (parentTraceRunId && typeof workflowTrace?.getRun === 'function') { + try { + const parentTrace = await workflowTrace.getRun(parentTraceRunId); + parentTraceSessionId = parentTrace?.conversationId || null; + } catch { /* lineage lookup is best-effort and must not fail a run */ } + } + if (run.capture === 'video') { + try { + if (!startRecording || !stopRecording) throw new Error('Cloud run video capture is unavailable.'); + const recording = await startRecording(tabId, { + video: true, + mic: false, + showBanner: false, + filename: `webbrain-ci-${run.runId}.webm`, + }); + if (!recording?.ok) throw new Error(recording?.error || 'Cloud run video capture could not start.'); + recordingId = recording.state?.recordingId || null; + } catch (captureError) { + pushUpdate(run, 'capture_error', { + kind: 'video', + message: captureError?.message || String(captureError), + }); + throw captureError; + } + pushUpdate(run, 'artifact_started', { + kind: 'video', + filename: `webbrain-ci-${run.runId}.webm`, + }); + } + if (grantApiMutationsForRun) agent.setTemporaryApiMutationsAllowed(tabId, true); + sendIndicator(tabId, 'WB_SHOW_AGENT_INDICATORS'); + const publishUpdate = (type, data) => pushUpdate( + run, + type, + workflow && type === 'text_delta' + ? { ...(data || {}), content: '[workflow output redacted]' } + : data, + workflowParameterValues, + ); + let content; + if (workflow) { + const replay = await agent.replaySavedWorkflow( + tabId, + workflow, + workflowParameters, + publishUpdate, + { cloudRun: true, independentRun: true }, + ); + content = redactWorkflowValue(replay.summary || ''); + run.summary = redactWorkflowValue(replay.summary || run.summary); + if (replay.status === 'fallback') { + pushUpdate(run, 'workflow_fallback', { + workflowId: workflow.id, + stepIndex: replay.stepIndex, + reason: replay.reason, + }); + content = redactWorkflowValue(await agent.processMessage( + tabId, replay.prompt, publishUpdate, 'act', [], { + cloudRun: true, + independentRun: true, + preserveRichTextToolbarAudit: true, + }, + )); + } else if (replay.status === 'stopped') { + run.status = 'failed'; + run.error = redactWorkflowValue( + replay.summary || replay.reason || 'Saved workflow stopped safely.' + ); + } + } else { + content = await agent.processMessage(tabId, task, publishUpdate, mode, [], { + cloudRun: true, + independentRun: true, + apiMutationsDenied: mode === 'ask', + outputSchema, + onTraceStarted(traceRunId) { + run.traceRunId = traceRunId; + schedulePersist(); + }, + parentRunId: parentTraceRunId, + parentSessionId: parentTraceSessionId, + }); + } + run.pendingInput = null; + // Terminal fields are published over the bridge and persisted just like + // update rows, so they get the same strict treatment — structured or + // not. An unstructured strict run used to return its final answer raw, + // which is where a model is most likely to repeat the credential it was + // told not to. Value redaction rather than blanking, because for an + // unstructured run this text *is* the result: the answer survives with + // the literal struck. + run.content = strictSecretMode && structured + ? (run.summary || '[redacted strict structured completion]') + : redactStrictTerminal(run, redactWorkflowValue(content), strictSecretMode); + run.finalUrl = cloudTerminalUrl(redactWorkflowValue(await getTabUrl(tabId)), { strictSecretMode }); + if (run.status === 'aborting') { + run.status = 'aborted'; + run.error = run.error || 'Aborted by cloud_abort.'; + } else if (run.status !== 'failed') { + if (structured && run.result === undefined) { + run.status = 'failed'; + run.error = 'Structured cloud run finished without a valid done_json result.'; + } else { + run.status = 'completed'; + if (!structured) { + run.result = redactStrictTerminal(run, redactWorkflowValue(content), strictSecretMode); + } + } + } + } catch (error) { + run.pendingInput = null; + run.status = run.status === 'aborting' ? 'aborted' : 'failed'; + run.error = redactStrictTerminal( + run, redactWorkflowValue(error?.message || String(error)), strictSecretMode, + ); + run.finalUrl = cloudTerminalUrl(redactWorkflowValue(await getTabUrl(tabId)), { strictSecretMode }); + } finally { + startingTabs.delete(tabId); + // The bridge flag is a run-scoped grant, not the sidebar's persistent + // /allow-api setting. Revoke only permission this run added; preserve a + // pre-existing per-conversation or global user grant. + if (grantApiMutationsForRun) agent.setTemporaryApiMutationsAllowed(tabId, false); + + // Do not expose a terminal status until the requested recording has + // finished flushing to Downloads; pollers use terminality as the cue + // that traces and artifacts are complete. + const terminalStatus = recordingId && TERMINAL_STATUSES.has(run.status) + ? run.status + : null; + if (terminalStatus) run.status = 'running'; + if (recordingId) { + try { + const capture = await stopRecording({ expectedRecordingId: recordingId }); + if (!capture?.ok) throw new Error(capture?.error || 'Cloud run video capture could not stop.'); + pushUpdate(run, 'artifact', { + kind: 'video', + filename: capture.filename || `webbrain-ci-${run.runId}.webm`, + }); + } catch (captureError) { + pushUpdate(run, 'capture_error', { + kind: 'video', + message: captureError?.message || String(captureError), + }); + } + } + if (terminalStatus) run.status = terminalStatus; + run.completedAt = isoNow(); + run.updatedAt = run.completedAt; + // The run is over, so nothing more can quote these; do not hold the + // literals in memory any longer than the run that typed them. + strictSecretValues.delete(run.runId); + strictTraceValues.delete(run.runId); + strictSecretOverflowRuns.delete(run.runId); + strictTraceOverflowRuns.delete(run.runId); + sendIndicator(tabId, 'WB_HIDE_AGENT_INDICATORS'); + await persist().catch(() => {}); + } + })(); + + return cloudSnapshot(run, { includeUpdates: false }); + } + + async function startWorkflowRun(msg = {}) { + const workflow = normalizeSavedWorkflow(msg.workflow); + if (!workflow) throw cloudRunError('Saved workflow is missing or invalid.', 400); + if (msg.parentRunId || msg.parent_run_id) { + throw cloudRunError('Saved workflow runs cannot be continuations.', 400); + } + const parameters = validateWorkflowParameters( + workflow, + msg.parameters ?? {}, + ); + return startRun({ + ...msg, + task: '', + text: '', + outputSchema: null, + output_schema: null, + _workflow: workflow, + _workflowParameters: parameters, + }); + } + + async function compileWorkflow(msg = {}) { + await hydrate(); + const runId = String(msg.runId || msg.run_id || '').trim(); + const name = String(msg.name || '').trim(); + if (!runId) return { ok: false, status: 400, reason: 'run_required', warnings: [] }; + if (!name) return { ok: false, status: 400, reason: 'name_required', warnings: [] }; + const run = runs.get(runId); + if (!run) return { ok: false, status: 404, reason: 'run_not_found', warnings: [] }; + if (run.workflowId) { + return { ok: false, status: 422, reason: 'workflow_run_not_compilable', warnings: [] }; + } + if (run.status !== 'completed') { + return { ok: false, status: 409, reason: 'successful_run_required', warnings: [] }; + } + if (!run.traceRunId || !workflowTrace) { + return { ok: false, status: 409, reason: 'trace_unavailable', warnings: [] }; + } + const draft = typeof agent.getLatestWorkflowDraft === 'function' + ? await agent.getLatestWorkflowDraft(run.tabId) + : null; + let compiled = draft?.sourceRunId === run.traceRunId + ? finalizeSavedWorkflowDraft(draft, { name }) + : null; + if (!compiled) { + for (let attempt = 0; attempt < 10; attempt += 1) { + compiled = await compileSuccessfulWorkflowByRunId(workflowTrace, { + runId: run.traceRunId, + name, + }); + if (compiled.workflow || compiled.reason !== 'successful_run_required') break; + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + if (!compiled?.workflow) { + return { + ok: false, + status: compiled?.reason === 'no_replayable_steps' ? 422 : 409, + reason: compiled?.reason || 'workflow_compilation_failed', + warnings: compiled?.warnings || [], + }; + } + return { ok: true, workflow: compiled.workflow, warnings: compiled.warnings || [] }; + } + + async function status(msg = {}) { + await hydrate(); + const runId = msg.runId || msg.run_id; + if (!runId) return { runs: [...runs.values()].map(run => cloudSnapshot(run, { includeUpdates: false })) }; + const run = runs.get(runId); + if (!run) throw new Error('Unknown cloud run.'); + return cloudSnapshot(run); + } + + async function abort(msg = {}) { + await hydrate(); + const run = runs.get(msg.runId || msg.run_id); + if (!run) throw new Error('Unknown cloud run.'); + if (run.status === 'running' || run.status === 'needs_user_input') { + run.status = 'aborting'; + run.pendingInput = null; + run.error = 'Abort requested.'; + run.updatedAt = isoNow(); + agent.abort(run.tabId); + await persist(); + } + return cloudSnapshot(run); + } + + async function respond(msg = {}) { + await hydrate(); + const run = runs.get(msg.runId || msg.run_id); + if (!run) throw cloudRunError('Unknown cloud run.', 404); + if (run.status !== 'needs_user_input') { + throw cloudRunError('Cloud run is not waiting for user input.', 409); + } + const clarifyId = String(msg.clarifyId || msg.clarify_id || '').trim(); + if (!clarifyId) throw cloudRunError('cloud_respond requires `clarify_id`.', 400); + const pendingClarifyId = String(run.pendingInput?.clarifyId || run.pendingInput?.clarify_id || '').trim(); + if (!pendingClarifyId || clarifyId !== pendingClarifyId) { + throw cloudRunError('Clarification is no longer pending for this cloud run.', 409); + } + const answer = String(msg.answer ?? '').trim(); + if (!answer) throw cloudRunError('cloud_respond requires `answer`.', 400); + if (!agent.submitClarifyResponse(run.tabId, clarifyId, answer, 'cloud_api')) { + throw cloudRunError('Clarification is no longer available in the active WebBrain run.', 409); + } + run.status = 'running'; + run.pendingInput = null; + run.error = ''; + pushUpdate(run, 'clarify_response', { clarifyId, source: 'cloud_api' }); + await persist(); + return cloudSnapshot(run); + } + + // Persistent bridge identity (chrome.storage.local). The token is the Cloud + // Bridge credential only; it is unrelated to provider API keys. + // + // Concurrent starts (a settings save triggers syncBridge while the page also + // sends cloud_bridge_start) must not each mint an installation id, so the + // read-or-create step is single-flight. + let installationIdInit = null; + function ensureInstallationId() { + if (!installationIdInit) { + installationIdInit = (async () => { + const stored = await api.storage.local.get('webbrainCloudBridgeInstallationId'); + if (stored.webbrainCloudBridgeInstallationId) return stored.webbrainCloudBridgeInstallationId; + const created = crypto.randomUUID(); + await api.storage.local.set({ webbrainCloudBridgeInstallationId: created }); + return created; + })().finally(() => { installationIdInit = null; }); + } + return installationIdInit; + } + + async function bridgeIdentity() { + const installationId = await ensureInstallationId(); + const stored = await api.storage.local.get([ + 'webbrainCloudBridgeToken', + 'webbrainCloudBridgeBrowserId', + ]); + return { + token: stored.webbrainCloudBridgeToken || '', + browserId: stored.webbrainCloudBridgeBrowserId || installationId, + installationId, + extensionVersion: api.runtime.getManifest?.().version || '', + }; + } + + async function startBridge(url = DEFAULT_CLOUD_BRIDGE_URL) { + return bridge.start({ + url: normalizeCloudBridgeUrl(url), + ...(await bridgeIdentity()), + }); + } + + async function stopBridge() { + return bridge.stop(); + } + + async function bridgeStatus() { + return bridge.status(); + } + + async function syncBridge() { + const stored = await api.storage.local.get(['webbrainCloudBridgeEnabled', 'webbrainCloudBridgeUrl']); + if (!stored.webbrainCloudBridgeEnabled) return stopBridge().catch(() => ({ enabled: false, connected: false })); + return startBridge(stored.webbrainCloudBridgeUrl || DEFAULT_CLOUD_BRIDGE_URL); + } + + return { + runs, + isRunning: (tabId) => startingTabs.has(tabId), + startRun, + startWorkflowRun, + compileWorkflow, + status, + respond, + abort, + startBridge, + stopBridge, + bridgeStatus, + syncBridge, + hydrate, + }; +} diff --git a/src/firefox/src/ui/locales/ar.js b/src/firefox/src/ui/locales/ar.js index 4d21438da..9ffd96384 100644 --- a/src/firefox/src/ui/locales/ar.js +++ b/src/firefox/src/ui/locales/ar.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Arabic (ar). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'تكبير واجهة الإضافة', 'sp.ui_scale.decrease': 'تصغير واجهة الإضافة', 'sp.ui_scale.increase': 'تكبير واجهة الإضافة', diff --git a/src/firefox/src/ui/locales/bn.js b/src/firefox/src/ui/locales/bn.js index 4474e21bf..cdc4906f5 100644 --- a/src/firefox/src/ui/locales/bn.js +++ b/src/firefox/src/ui/locales/bn.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Bengali — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'এক্সটেনশনের UI জুম', 'sp.ui_scale.decrease': 'এক্সটেনশনের UI ছোট করুন', 'sp.ui_scale.increase': 'এক্সটেনশনের UI বড় করুন', diff --git a/src/firefox/src/ui/locales/cloud-bridge-copy.mjs b/src/firefox/src/ui/locales/cloud-bridge-copy.mjs new file mode 100644 index 000000000..4df7ff44d --- /dev/null +++ b/src/firefox/src/ui/locales/cloud-bridge-copy.mjs @@ -0,0 +1,22 @@ +// English fallback for the Cloud Bridge settings tab; shared to keep locale keys aligned. +export default { + "st.cb.invalid_url": "Use a local ws:// URL with 127.0.0.1, localhost, or ::1.", + "st.cb.status_disabled": "Bridge disabled", + "st.cb.status_error": "Connection error: {error}", + "st.tab.cloudbridge": 'Cloud Bridge', + "st.cb.enabled": 'Let a backend control this browser', + "st.cb.desc": 'WebBrain connects out to the backend below and waits for cloud_* commands. With a token set, the backend must approve this browser before any command runs.', + "st.cb.url": 'Backend WebSocket URL (local ws:// only)', + "st.cb.token": 'Cloud Bridge token', + "st.cb.token_hint": 'Leave empty for a plain local controller (no approval step). This is not a provider API key.', + "st.cb.browser_name": 'Browser name', + "st.cb.installation_id": 'Installation ID:', + "st.cb.test": 'Save & test connection', + "st.cb.testing": 'Testing…', + "st.cb.connected": 'Connected (no approval required).', + "st.cb.pending": 'Connected — waiting for the backend to approve this browser.', + "st.cb.approved": 'Connected and approved.', + "st.cb.rejected": 'Rejected by the backend: {reason}', + "st.cb.unreachable": 'Backend unreachable at {url}.', + "st.cb.howto_html": 'To try it locally, run node examples/cloud-bridge-approval-server.mjs --token YOUR_TOKEN from the WebBrain checkout, enter the same token here, then press “Save & test connection”. Type approve in that terminal. Documentation', +}; diff --git a/src/firefox/src/ui/locales/de.js b/src/firefox/src/ui/locales/de.js index 09d18072c..6891babaa 100644 --- a/src/firefox/src/ui/locales/de.js +++ b/src/firefox/src/ui/locales/de.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // German (de). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Erweiterungsoberfläche zoomen', 'sp.ui_scale.decrease': 'Erweiterungsoberfläche verkleinern', 'sp.ui_scale.increase': 'Erweiterungsoberfläche vergrößern', diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js index 5769c6be6..62e5d6f21 100644 --- a/src/firefox/src/ui/locales/en.js +++ b/src/firefox/src/ui/locales/en.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // English — canonical locale. Other locales inherit key names from this file. import apocalypseModeCopy from './apocalypse-copy.mjs'; import emergencyCopy from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Extension UI zoom', 'sp.ui_scale.decrease': 'Zoom extension UI out', 'sp.ui_scale.increase': 'Zoom extension UI in', diff --git a/src/firefox/src/ui/locales/es.js b/src/firefox/src/ui/locales/es.js index 80b1e0c5b..777825e4c 100644 --- a/src/firefox/src/ui/locales/es.js +++ b/src/firefox/src/ui/locales/es.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Spanish (es). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom de la interfaz de la extensión', 'sp.ui_scale.decrease': 'Reducir el zoom de la interfaz', 'sp.ui_scale.increase': 'Aumentar el zoom de la interfaz', diff --git a/src/firefox/src/ui/locales/fa.js b/src/firefox/src/ui/locales/fa.js index d7022c6a5..c2f05f43c 100644 --- a/src/firefox/src/ui/locales/fa.js +++ b/src/firefox/src/ui/locales/fa.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Persian — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'بزرگ‌نمایی رابط کاربری افزونه', 'sp.ui_scale.decrease': 'کاهش بزرگ‌نمایی رابط افزونه', 'sp.ui_scale.increase': 'افزایش بزرگ‌نمایی رابط افزونه', diff --git a/src/firefox/src/ui/locales/fr.js b/src/firefox/src/ui/locales/fr.js index b8a0df62d..ae3b9f7bd 100644 --- a/src/firefox/src/ui/locales/fr.js +++ b/src/firefox/src/ui/locales/fr.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // French (fr). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom de l’interface de l’extension', 'sp.ui_scale.decrease': 'Réduire le zoom de l’interface', 'sp.ui_scale.increase': 'Augmenter le zoom de l’interface', diff --git a/src/firefox/src/ui/locales/he.js b/src/firefox/src/ui/locales/he.js index 98434aef9..4d8358a36 100644 --- a/src/firefox/src/ui/locales/he.js +++ b/src/firefox/src/ui/locales/he.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Hebrew (he). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'הגדלת ממשק התוסף', 'sp.ui_scale.decrease': 'הקטנת תצוגת ממשק התוסף', 'sp.ui_scale.increase': 'הגדלת תצוגת ממשק התוסף', diff --git a/src/firefox/src/ui/locales/hi.js b/src/firefox/src/ui/locales/hi.js index 91e12d9fe..bafcaf317 100644 --- a/src/firefox/src/ui/locales/hi.js +++ b/src/firefox/src/ui/locales/hi.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Hindi — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'एक्सटेंशन UI ज़ूम', 'sp.ui_scale.decrease': 'एक्सटेंशन UI को छोटा करें', 'sp.ui_scale.increase': 'एक्सटेंशन UI को बड़ा करें', diff --git a/src/firefox/src/ui/locales/id.js b/src/firefox/src/ui/locales/id.js index e27bad9e5..1f4249354 100644 --- a/src/firefox/src/ui/locales/id.js +++ b/src/firefox/src/ui/locales/id.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Indonesian (id). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom UI ekstensi', 'sp.ui_scale.decrease': 'Perkecil UI ekstensi', 'sp.ui_scale.increase': 'Perbesar UI ekstensi', diff --git a/src/firefox/src/ui/locales/ja.js b/src/firefox/src/ui/locales/ja.js index 18dde6bda..bc3d0fe10 100644 --- a/src/firefox/src/ui/locales/ja.js +++ b/src/firefox/src/ui/locales/ja.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Japanese (ja). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': '拡張機能 UI のズーム', 'sp.ui_scale.decrease': '拡張機能 UI を縮小', 'sp.ui_scale.increase': '拡張機能 UI を拡大', diff --git a/src/firefox/src/ui/locales/ko.js b/src/firefox/src/ui/locales/ko.js index 3a1ad07b2..c17bcaa68 100644 --- a/src/firefox/src/ui/locales/ko.js +++ b/src/firefox/src/ui/locales/ko.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Korean (ko). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': '확장 프로그램 UI 확대/축소', 'sp.ui_scale.decrease': '확장 프로그램 UI 축소', 'sp.ui_scale.increase': '확장 프로그램 UI 확대', diff --git a/src/firefox/src/ui/locales/ms.js b/src/firefox/src/ui/locales/ms.js index 951012b13..5062754b3 100644 --- a/src/firefox/src/ui/locales/ms.js +++ b/src/firefox/src/ui/locales/ms.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Malay (ms). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zum UI sambungan', 'sp.ui_scale.decrease': 'Kecilkan UI sambungan', 'sp.ui_scale.increase': 'Besarkan UI sambungan', diff --git a/src/firefox/src/ui/locales/nl.js b/src/firefox/src/ui/locales/nl.js index 506d1e93f..17cc09884 100644 --- a/src/firefox/src/ui/locales/nl.js +++ b/src/firefox/src/ui/locales/nl.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Dutch (nl). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom van extensie-interface', 'sp.ui_scale.decrease': 'Extensie-interface uitzoomen', 'sp.ui_scale.increase': 'Extensie-interface inzoomen', diff --git a/src/firefox/src/ui/locales/pl.js b/src/firefox/src/ui/locales/pl.js index 472d4d8f2..4a74e171d 100644 --- a/src/firefox/src/ui/locales/pl.js +++ b/src/firefox/src/ui/locales/pl.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Polski — translated from en.js. Keys mirror the English canonical file. import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Powiększenie interfejsu rozszerzenia', 'sp.ui_scale.decrease': 'Pomniejsz interfejs rozszerzenia', 'sp.ui_scale.increase': 'Powiększ interfejs rozszerzenia', diff --git a/src/firefox/src/ui/locales/pt.js b/src/firefox/src/ui/locales/pt.js index 5a31427f6..a239fbdd0 100644 --- a/src/firefox/src/ui/locales/pt.js +++ b/src/firefox/src/ui/locales/pt.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Portuguese — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Zoom da interface da extensão', 'sp.ui_scale.decrease': 'Reduzir o zoom da interface', 'sp.ui_scale.increase': 'Aumentar o zoom da interface', diff --git a/src/firefox/src/ui/locales/ru.js b/src/firefox/src/ui/locales/ru.js index 4a5a6d8e0..f14672599 100644 --- a/src/firefox/src/ui/locales/ru.js +++ b/src/firefox/src/ui/locales/ru.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Russian (ru). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Масштаб интерфейса расширения', 'sp.ui_scale.decrease': 'Уменьшить интерфейс расширения', 'sp.ui_scale.increase': 'Увеличить интерфейс расширения', diff --git a/src/firefox/src/ui/locales/th.js b/src/firefox/src/ui/locales/th.js index 4309916e5..f747b25f1 100644 --- a/src/firefox/src/ui/locales/th.js +++ b/src/firefox/src/ui/locales/th.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Thai (th). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'ซูม UI ของส่วนขยาย', 'sp.ui_scale.decrease': 'ย่อ UI ของส่วนขยาย', 'sp.ui_scale.increase': 'ขยาย UI ของส่วนขยาย', diff --git a/src/firefox/src/ui/locales/tl.js b/src/firefox/src/ui/locales/tl.js index 4f38631d8..d120bd0f8 100644 --- a/src/firefox/src/ui/locales/tl.js +++ b/src/firefox/src/ui/locales/tl.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Filipino / Tagalog (tl). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Pag-zoom ng UI ng extension', 'sp.ui_scale.decrease': 'Bawasan ang zoom ng UI ng extension', 'sp.ui_scale.increase': 'Dagdagan ang zoom ng UI ng extension', diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js index 83b6922fd..fc32d5029 100644 --- a/src/firefox/src/ui/locales/tr.js +++ b/src/firefox/src/ui/locales/tr.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Turkish (tr). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Uzantı arayüzü yakınlaştırması', 'sp.ui_scale.decrease': 'Uzantı arayüzünü uzaklaştır', 'sp.ui_scale.increase': 'Uzantı arayüzünü yakınlaştır', diff --git a/src/firefox/src/ui/locales/uk.js b/src/firefox/src/ui/locales/uk.js index 4b5492f9f..130f03bc4 100644 --- a/src/firefox/src/ui/locales/uk.js +++ b/src/firefox/src/ui/locales/uk.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Ukrainian (uk). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Масштаб інтерфейсу розширення', 'sp.ui_scale.decrease': 'Зменшити інтерфейс розширення', 'sp.ui_scale.increase': 'Збільшити інтерфейс розширення', diff --git a/src/firefox/src/ui/locales/vi.js b/src/firefox/src/ui/locales/vi.js index be0c47b3f..f296405d0 100644 --- a/src/firefox/src/ui/locales/vi.js +++ b/src/firefox/src/ui/locales/vi.js @@ -1,10 +1,12 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Vietnamese — translated from the canonical English locale. import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': 'Thu phóng giao diện tiện ích', 'sp.ui_scale.decrease': 'Thu nhỏ giao diện tiện ích', 'sp.ui_scale.increase': 'Phóng to giao diện tiện ích', diff --git a/src/firefox/src/ui/locales/zh.js b/src/firefox/src/ui/locales/zh.js index 16ed61f32..cddf7eb1c 100644 --- a/src/firefox/src/ui/locales/zh.js +++ b/src/firefox/src/ui/locales/zh.js @@ -1,4 +1,5 @@ import bidiCopy from './bidi-copy.mjs'; +import cloudBridgeCopy from './cloud-bridge-copy.mjs'; // Simplified Chinese (zh). import chromeWebStoreLocale from './chrome-web-store.mjs'; @@ -7,6 +8,7 @@ import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { ...bidiCopy, + ...cloudBridgeCopy, 'sp.ui_scale.label': '插件界面缩放', 'sp.ui_scale.decrease': '缩小插件界面', 'sp.ui_scale.increase': '放大插件界面', diff --git a/src/firefox/src/ui/settings-cloud-bridge.js b/src/firefox/src/ui/settings-cloud-bridge.js new file mode 100644 index 000000000..ef96246ef --- /dev/null +++ b/src/firefox/src/ui/settings-cloud-bridge.js @@ -0,0 +1,117 @@ +// Settings → Cloud Bridge tab: URL, token, browser name, connection test. +// Uses the same storage keys as the Display → MCP block; identity keys are +// read by cloud-runs.js when the bridge starts. +import { t } from './i18n.js'; + +const KEYS = { + enabled: 'webbrainCloudBridgeEnabled', + url: 'webbrainCloudBridgeUrl', + token: 'webbrainCloudBridgeToken', + browserId: 'webbrainCloudBridgeBrowserId', + installationId: 'webbrainCloudBridgeInstallationId', +}; +const DEFAULT_URL = 'ws://127.0.0.1:17374/extension'; +const $ = (id) => document.getElementById(id); +const enabled = $('cb-enabled'); +const urlInput = $('cb-url'); +const tokenInput = $('cb-token'); +const nameInput = $('cb-browser-id'); +const installationEl = $('cb-installation-id'); +const statusEl = $('cb-status'); +const statusText = $('cb-status-text'); +const testBtn = $('cb-test'); + +async function send(action, data = {}) { + const response = await browser.runtime.sendMessage({ target: 'background', action, ...data }); + if (response?.error) throw new Error(response.error); + return response; +} + +function setStatus(state, message) { + statusEl.dataset.state = state; + statusText.textContent = message; +} + +function normalizeUrl(value) { + const url = new URL(String(value || DEFAULT_URL)); + if (url.protocol !== 'ws:' || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname.toLowerCase())) { + throw new Error(t('st.cb.invalid_url')); + } + return url.href; +} + +function render(status = {}) { + if (!enabled.checked || status.enabled === false) return setStatus('disabled', t('st.cb.status_disabled')); + if (status.installationId) installationEl.textContent = status.installationId; + if (status.approval === 'rejected') return setStatus('error', t('st.cb.rejected', { reason: status.lastError || '' })); + if (status.connected) { + if (status.approval === 'approved') return setStatus('connected', t('st.cb.approved')); + if (status.approval === 'pending') return setStatus('waiting', t('st.cb.pending')); + return setStatus('connected', t('st.cb.connected')); + } + if (status.lastError && status.lastError !== 'WebSocket error') return setStatus('error', t('st.cb.status_error', { error: status.lastError })); + setStatus('waiting', t('st.cb.unreachable', { url: status.url || urlInput.value || DEFAULT_URL })); +} + +async function load() { + const stored = await browser.storage.local.get(Object.values(KEYS)); + enabled.checked = !!stored[KEYS.enabled]; + urlInput.value = stored[KEYS.url] || DEFAULT_URL; + tokenInput.value = stored[KEYS.token] || ''; + nameInput.value = stored[KEYS.browserId] || ''; + installationEl.textContent = stored[KEYS.installationId] || '—'; + if (enabled.checked) refresh(); + else render({ enabled: false }); +} + +async function refresh() { + if (!enabled.checked || document.hidden) return; + try { render(await send('cloud_bridge_status')); } catch (e) { setStatus('error', e.message); } +} + +async function save() { + const url = normalizeUrl(urlInput.value); + urlInput.value = url; + const patch = { [KEYS.url]: url, [KEYS.enabled]: enabled.checked, [KEYS.token]: tokenInput.value.trim() }; + const name = nameInput.value.trim(); + await browser.storage.local.set(patch); + if (name) await browser.storage.local.set({ [KEYS.browserId]: name }); + else await browser.storage.local.remove(KEYS.browserId); + return url; +} + +async function testConnection() { + testBtn.disabled = true; + setStatus('waiting', t('st.cb.testing')); + try { + enabled.checked = true; // testing implies connecting + const url = await save(); + let status = await send('cloud_bridge_start', { url }); + for (let i = 0; i < 16; i++) { + await new Promise((r) => setTimeout(r, 500)); + status = await send('cloud_bridge_status'); + if (status.approval === 'rejected' || status.approval === 'approved' || (status.connected && status.approval === 'not_required')) break; + } + render(status); + const stored = await browser.storage.local.get(KEYS.installationId); + installationEl.textContent = stored[KEYS.installationId] || '—'; + } catch (e) { + setStatus('error', e.message); + } finally { + testBtn.disabled = false; + } +} + +enabled.addEventListener('change', async () => { + try { + await save(); + if (enabled.checked) render(await send('cloud_bridge_start', { url: urlInput.value })); + else { await send('cloud_bridge_stop').catch(() => null); render({ enabled: false }); } + } catch (e) { setStatus('error', e.message); } +}); +testBtn.addEventListener('click', testConnection); +setInterval(refresh, 2000); +browser.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && Object.values(KEYS).some((k) => changes[k]) && !document.activeElement?.closest('#cb-card')) load(); +}); +load(); diff --git a/src/firefox/src/ui/settings.html b/src/firefox/src/ui/settings.html index 5dc4ed834..7dbef702b 100644 --- a/src/firefox/src/ui/settings.html +++ b/src/firefox/src/ui/settings.html @@ -912,6 +912,57 @@ font-size: 11px; cursor: pointer; } + .cloud-bridge-url-field { + display: block; + width: 100%; + } + .cloud-bridge-url-label { + display: block; + margin-bottom: 5px; + color: var(--text2); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.55px; + text-transform: uppercase; + } + .cloud-bridge-url-field .setting-input { + width: 100%; + max-width: none; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + } + .cloud-bridge-url-field .setting-input[aria-invalid="true"] { + border-color: var(--error); + box-shadow: 0 0 0 2px rgba(244,67,54,0.16); + } + .cloud-bridge-status { + display: flex; + align-items: center; + gap: 7px; + min-height: 16px; + color: var(--text2); + font-size: 11px; + line-height: 1.35; + } + .cloud-bridge-status-dot { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 50%; + background: currentColor; + } + .cloud-bridge-status[data-state="connected"] { color: var(--success); } + .cloud-bridge-status[data-state="waiting"] { color: var(--accent); } + .cloud-bridge-status[data-state="error"] { color: var(--error); } + .cloud-bridge-status[data-state="waiting"] .cloud-bridge-status-dot { + animation: cloud-bridge-pulse 1.4s ease-in-out infinite; + } + @keyframes cloud-bridge-pulse { + 50% { opacity: 0.35; transform: scale(0.78); } + } + @media (prefers-reduced-motion: reduce) { + .cloud-bridge-status[data-state="waiting"] .cloud-bridge-status-dot { animation: none; } + } .skill-source { max-width: 340px; overflow: hidden; @@ -1269,6 +1320,7 @@

+
@@ -2281,12 +2333,50 @@

+
+
+
+
+
+
+
+ +
+ + +
+ +
—
+
+ +
+
+ + +
+
+
+
+ + diff --git a/test/cloud-bridge-approval.mjs b/test/cloud-bridge-approval.mjs new file mode 100644 index 000000000..d1a8394fb --- /dev/null +++ b/test/cloud-bridge-approval.mjs @@ -0,0 +1,376 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; +import { startApprovalServer } from '../examples/cloud-bridge-approval-server.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const source = fs.readFileSync(path.join(ROOT, 'src/chrome/src/offscreen/cloud-bridge.js'), 'utf8'); +const IDENTITY = { token: 'secret-token', browserId: 'browser-1', installationId: 'install-1', extensionVersion: '1.2.3' }; +const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); + +function harness({ WebSocketImpl, sendMessage = async () => ({ runId: 'r1', status: 'running' }) } = {}) { + const sockets = []; + const timers = []; + const runtimeCalls = []; + let listener; + class FakeWebSocket { + static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; + constructor(url) { this.url = url; this.readyState = 0; this.listeners = new Map(); this.sent = []; sockets.push(this); } + addEventListener(type, cb) { this.listeners.set(type, cb); } + send(v) { this.sent.push(JSON.parse(v)); } + close() { this.readyState = 2; this.emit('close'); } + emit(type, value = {}) { + if (type === 'open') this.readyState = 1; + if (type === 'close') this.readyState = 3; + this.listeners.get(type)?.(value); + } + receive(obj) { return this.emit('message', { data: JSON.stringify(obj) }); } + } + vm.runInNewContext(source, { + URL, + WebSocket: WebSocketImpl || FakeWebSocket, + navigator: { userAgent: 'Mozilla/5.0 Chrome/126.0.1 Safari/537.36', platform: 'Linux x86_64' }, + chrome: { runtime: { + onMessage: { addListener: cb => { listener = cb; } }, + sendMessage: async m => { runtimeCalls.push(m); return sendMessage(m); }, + } }, + setTimeout: (callback, delay) => { timers.push({ callback, delay, cleared: false }); return timers.length; }, + clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; }, + }); + const start = (extra = IDENTITY, url = 'ws://127.0.0.1:17374/extension') => { + let out; listener({ type: 'cloud-bridge-start', url, ...extra }, null, v => { out = v; }); return out; + }; + const status = () => { let out; listener({ type: 'cloud-bridge-status' }, null, v => { out = v; }); return out; }; + return { sockets, timers, runtimeCalls, start, status }; +} + +const cmd = { id: 'c1', action: 'cloud_status', payload: { runId: 'r1' } }; + +test('hello carries identity, token, browser, version and platform', () => { + const h = harness(); + h.start(); + h.sockets[0].emit('open'); + const hello = h.sockets[0].sent[0]; + assert.equal(hello.type, 'hello'); + assert.deepEqual(hello.auth, { type: 'bearer', token: 'secret-token' }); + assert.equal(hello.browserId, 'browser-1'); + assert.equal(hello.installationId, 'install-1'); + assert.deepEqual({ ...hello.browser }, { name: 'Chrome', version: '126.0.1' }); + assert.equal(hello.extensionVersion, '1.2.3'); + assert.equal(hello.platform, 'Linux x86_64'); + assert.deepEqual([...hello.capabilities], ['saved_workflows_v1', 'run_modes_v1', 'scheduled_jobs_v1']); + assert.equal(JSON.stringify(hello.status).includes('secret-token'), false, 'status must not leak the token'); + assert.equal(JSON.stringify(h.status()).includes('secret-token'), false); +}); + +test('pending connection refuses cloud commands without calling the background', async () => { + const h = harness(); + h.start(); + const s = h.sockets[0]; + s.emit('open'); + s.receive({ type: 'connection_pending', browserId: 'browser-1' }); + assert.equal(h.status().approval, 'pending'); + s.receive(cmd); + await tick(); + const reply = s.sent.find(m => m.id === 'c1'); + assert.equal(reply.ok, false); + assert.equal(reply.code, 'connection_not_approved'); + assert.equal(h.runtimeCalls.length, 0); +}); + +test('commands are refused before any approval message too', async () => { + const h = harness(); + h.start(); + h.sockets[0].emit('open'); + h.sockets[0].receive(cmd); + await tick(); + assert.equal(h.sockets[0].sent.find(m => m.id === 'c1').ok, false); + assert.equal(h.runtimeCalls.length, 0); +}); + +test('approved connection runs commands with the unchanged payload format', async () => { + const h = harness(); + h.start(); + const s = h.sockets[0]; + s.emit('open'); + s.receive({ type: 'connection_pending' }); + s.receive({ type: 'connection_approved', browserId: 'browser-1' }); + assert.equal(h.status().approval, 'approved'); + s.receive(cmd); + await tick(); + assert.deepEqual({ ...h.runtimeCalls[0] }, { runId: 'r1', target: 'background', action: 'cloud_status' }); + const reply = s.sent.find(m => m.id === 'c1'); + assert.equal(reply.ok, true); + assert.equal(reply.result.status, 'running'); +}); + +test('approval for another browserId is ignored', async () => { + const h = harness(); + h.start(); + h.sockets[0].emit('open'); + h.sockets[0].receive({ type: 'connection_approved', browserId: 'someone-else' }); + assert.equal(h.status().approval, 'pending'); +}); + +test('rejected connection closes, blocks commands and does not reconnect', async () => { + const h = harness(); + h.start(); + const s = h.sockets[0]; + s.emit('open'); + s.receive({ type: 'connection_rejected', reason: 'nope' }); + assert.equal(h.status().approval, 'rejected'); + assert.equal(h.status().lastError, 'nope'); + assert.equal(s.readyState, 3); + assert.equal(h.timers.length, 0, 'no reconnect after rejection'); + s.receive(cmd); + await tick(); + assert.equal(h.runtimeCalls.length, 0); +}); + +test('reconnect requires a fresh approval', async () => { + const h = harness(); + h.start(); + const first = h.sockets[0]; + first.emit('open'); + first.receive({ type: 'connection_approved' }); + first.close(); + assert.equal(h.status().approval, 'pending'); + h.timers[0].callback(); + const second = h.sockets[1]; + second.emit('open'); + assert.equal(second.sent[0].type, 'hello'); + assert.equal(second.sent[0].auth.token, 'secret-token'); + second.receive(cmd); + await tick(); + assert.equal(second.sent.find(m => m.id === 'c1').code, 'connection_not_approved'); + assert.equal(h.runtimeCalls.length, 0); + second.receive({ type: 'connection_approved' }); + second.receive(cmd); + await tick(); + assert.equal(h.runtimeCalls.length, 1); +}); + +test('rejection cancels a pending backoff timer so the browser cannot reconnect', async () => { + const h = harness(); + h.start(); + h.sockets[0].emit('open'); + h.sockets[0].close(); // schedules a reconnect timer + assert.equal(h.timers.length, 1); + h.start(); // restart during backoff opens a new socket right away + h.sockets[1].emit('open'); + h.sockets[1].receive({ type: 'connection_rejected', reason: 'nope' }); + assert.equal(h.timers[0].cleared, true, 'pending timer must be cancelled'); +}); + +test('without a token the legacy behaviour is unchanged', async () => { + const h = harness(); + h.start({}); + const s = h.sockets[0]; + s.emit('open'); + assert.equal(s.sent[0].auth, undefined); + assert.equal(h.status().approval, 'not_required'); + s.receive(cmd); + await tick(); + assert.equal(h.runtimeCalls.length, 1); +}); + +test('end to end against the example server', async () => { + const received = []; + const server = await startApprovalServer({ port: 0, token: 'secret-token', onMessage: m => received.push(m) }); + const h = harness({ WebSocketImpl: WebSocket }); + try { + h.start(IDENTITY, `ws://127.0.0.1:${server.port}/extension`); + for (let i = 0; i < 50 && ![...server.sessions].some(s => s.state === 'pending'); i++) await tick(20); + const session = [...server.sessions][0]; + assert.equal(session.state, 'pending'); + assert.equal(session.hello.browserId, 'browser-1'); + session.send(cmd); + for (let i = 0; i < 50 && !received.some(m => m.id === 'c1'); i++) await tick(20); + assert.equal(received.find(m => m.id === 'c1').code, 'connection_not_approved'); + session.approve(); + session.send({ ...cmd, id: 'c2' }); + for (let i = 0; i < 50 && !received.some(m => m.id === 'c2'); i++) await tick(20); + assert.equal(received.find(m => m.id === 'c2').ok, true); + } finally { + await server.close(); + } +}); + +// ---- Firefox: same protocol, socket lives in the background page ---- +const { createCloudBridge } = await import('../src/firefox/src/cloud-bridge.js'); +const { createCloudRunController: createFxController } = await import('../src/firefox/src/cloud-runs.js'); + +function fxHarness({ WebSocketImpl, dispatch } = {}) { + const sockets = []; + const calls = []; + class FakeWebSocket { + static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; + constructor(url) { this.url = url; this.readyState = 0; this.listeners = new Map(); this.sent = []; sockets.push(this); } + addEventListener(type, cb) { this.listeners.set(type, cb); } + send(v) { this.sent.push(JSON.parse(v)); } + close() { this.readyState = 3; this.listeners.get('close')?.({}); } + emit(type, value = {}) { if (type === 'open') this.readyState = 1; this.listeners.get(type)?.(value); } + receive(obj) { return this.emit('message', { data: JSON.stringify(obj) }); } + } + const bridge = createCloudBridge({ + WebSocketImpl: WebSocketImpl || FakeWebSocket, + nav: { userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0', platform: 'Linux x86_64' }, + dispatch: dispatch || (async (m) => { calls.push(m); return { runId: 'r1', status: 'running' }; }), + }); + return { bridge, sockets, calls }; +} + +test('firefox: hello, pending refusal, approval, rejection', async () => { + const h = fxHarness(); + h.bridge.start({ url: 'ws://127.0.0.1:17374/extension', ...IDENTITY }); + const s = h.sockets[0]; + s.emit('open'); + const hello = s.sent[0]; + assert.deepEqual({ ...hello.browser }, { name: 'Firefox', version: '140.0' }); + assert.equal(hello.auth.token, 'secret-token'); + assert.equal(hello.browserId, 'browser-1'); + assert.equal(hello.platform, 'Linux x86_64'); + assert.equal(JSON.stringify(h.bridge.status()).includes('secret-token'), false); + + s.receive({ type: 'connection_pending' }); + s.receive(cmd); + await tick(); + assert.equal(s.sent.find(m => m.id === 'c1').code, 'connection_not_approved'); + assert.equal(h.calls.length, 0); + + s.receive({ type: 'connection_approved', browserId: 'browser-1' }); + s.receive({ ...cmd, id: 'c2' }); + await tick(); + assert.equal(s.sent.find(m => m.id === 'c2').ok, true); + assert.deepEqual({ ...h.calls[0] }, { runId: 'r1', target: 'background', action: 'cloud_status' }); + + s.receive({ id: 'bad', action: 'get_providers', payload: {} }); + await tick(); + assert.match(s.sent.find(m => m.id === 'bad').error, /unsupported/i); + + s.receive({ type: 'connection_rejected', reason: 'no' }); + assert.equal(h.bridge.status().approval, 'rejected'); + assert.equal(s.readyState, 3); + h.bridge.stop(); +}); + +test('firefox: reconnect requires a fresh approval', async () => { + const h = fxHarness(); + h.bridge.start({ url: 'ws://127.0.0.1:17374/extension', ...IDENTITY }); + const first = h.sockets[0]; + first.emit('open'); + first.receive({ type: 'connection_approved' }); + first.close(); + assert.equal(h.bridge.status().approval, 'pending'); + await tick(700); // first backoff is 500 ms + const second = h.sockets[1]; + assert.ok(second, 'reconnected'); + second.emit('open'); + second.receive(cmd); + await tick(); + assert.equal(second.sent.find(m => m.id === 'c1').code, 'connection_not_approved'); + assert.equal(h.calls.length, 0); + h.bridge.stop(); +}); + +test('firefox: rejection cancels a pending backoff timer', async () => { + const h = fxHarness(); + h.bridge.start({ url: 'ws://127.0.0.1:17374/extension', ...IDENTITY }); + h.sockets[0].emit('open'); + h.sockets[0].close(); // schedules a 500 ms reconnect + h.bridge.start({ url: 'ws://127.0.0.1:17374/extension', ...IDENTITY }); + h.sockets[1].emit('open'); + h.sockets[1].receive({ type: 'connection_rejected' }); + await tick(700); + assert.equal(h.sockets.length, 2, 'no third socket after rejection'); + h.bridge.stop(); +}); + +test('firefox: without a token behaviour is legacy', async () => { + const h = fxHarness(); + h.bridge.start({ url: 'ws://127.0.0.1:17374/extension' }); + h.sockets[0].emit('open'); + assert.equal(h.sockets[0].sent[0].auth, undefined); + h.sockets[0].receive(cmd); + await tick(); + assert.equal(h.calls.length, 1); + assert.throws(() => { throw new Error(h.bridge.start({ url: 'wss://evil.example/x' }).error); }, /localhost/); + h.bridge.stop(); +}); + +test('firefox: controller persists identity and feeds it to the bridge', async () => { + const store = {}; + const started = []; + const api = { + storage: { local: { + get: async (keys) => Object.fromEntries([].concat(keys).map(k => [k, store[k]]).filter(([, v]) => v !== undefined)), + set: async (obj) => { Object.assign(store, obj); }, + } }, + runtime: { getManifest: () => ({ version: '9.9.9' }) }, + }; + const controller = createFxController({ + chromeApi: api, + agent: {}, + bridge: { start: (m) => { started.push(m); return { enabled: true }; }, stop: () => ({ enabled: false }), status: () => ({}) }, + }); + store.webbrainCloudBridgeEnabled = true; + store.webbrainCloudBridgeToken = 'tok'; + await controller.syncBridge(); + assert.equal(started[0].token, 'tok'); + assert.equal(started[0].extensionVersion, '9.9.9'); + assert.ok(store.webbrainCloudBridgeInstallationId, 'installation id generated once'); + assert.equal(started[0].browserId, store.webbrainCloudBridgeInstallationId, 'browserId defaults to the installation id'); + await controller.syncBridge(); + assert.equal(started[1].installationId, started[0].installationId, 'installation id is stable'); +}); + +test('firefox: end to end against the example server', async () => { + const received = []; + const server = await startApprovalServer({ port: 0, token: 'secret-token', onMessage: m => received.push(m) }); + const h = fxHarness({ WebSocketImpl: WebSocket }); + try { + h.bridge.start({ url: `ws://127.0.0.1:${server.port}/extension`, ...IDENTITY }); + for (let i = 0; i < 50 && ![...server.sessions].some(s => s.state === 'pending'); i++) await tick(20); + const session = [...server.sessions][0]; + assert.equal(session.state, 'pending'); + session.send(cmd); + for (let i = 0; i < 50 && !received.some(m => m.id === 'c1'); i++) await tick(20); + assert.equal(received.find(m => m.id === 'c1').code, 'connection_not_approved'); + session.approve(); + session.send({ ...cmd, id: 'c2' }); + for (let i = 0; i < 50 && !received.some(m => m.id === 'c2'); i++) await tick(20); + assert.equal(received.find(m => m.id === 'c2').ok, true); + } finally { + h.bridge.stop(); + await server.close(); + } +}); + +for (const [label, createController] of [['chrome', null], ['firefox', createFxController]]) { + test(`${label}: concurrent bridge starts share one installation id`, async () => { + const { createCloudRunController: make } = label === 'chrome' + ? await import('../src/chrome/src/cloud-runs.js') + : { createCloudRunController: createController }; + const store = {}; + const started = []; + const api = { + storage: { local: { + // yield so two callers can interleave between read and write + get: async (keys) => { await tick(5); return Object.fromEntries([].concat(keys).map(k => [k, store[k]]).filter(([, v]) => v !== undefined)); }, + set: async (obj) => { await tick(5); Object.assign(store, obj); }, + } }, + runtime: { getManifest: () => ({ version: '1.0.0' }) }, + }; + const bridge = { start: (m) => { started.push(m); return {}; }, stop: () => ({}), status: () => ({}) }; + const controller = make({ chromeApi: api, agent: {}, ensureOffscreen: async () => {}, bridge }); + store.webbrainCloudBridgeEnabled = true; + if (label === 'chrome') api.runtime.sendMessage = async (m) => { started.push(m); return {}; }; + await Promise.all([controller.syncBridge(), controller.startBridge(), controller.syncBridge()]); + const ids = new Set(started.map(m => m.installationId)); + assert.equal(ids.size, 1, 'all concurrent starts must use the same installation id'); + assert.equal([...ids][0], store.webbrainCloudBridgeInstallationId, 'and it matches the persisted one'); + }); +} diff --git a/test/run.js b/test/run.js index 76e8dd734..98dd6fdae 100644 --- a/test/run.js +++ b/test/run.js @@ -26047,7 +26047,24 @@ test('trace lineage: _startTraceRun and replay plumb parent ids in both builds', assert.match(chromeCloudRuns, /const parentTraceRunId = parentRun\?\.traceRunId \|\| null;/, 'cloud-runs does not use the completed parent trace'); assert.match(chromeCloudRuns, /workflowTrace\.getRun\(parentTraceRunId\)/, 'cloud-runs does not resolve the parent trace session'); assert.match(chromeCloudRuns, /parentRunId: parentTraceRunId,[\s\S]*?parentSessionId: parentTraceSessionId,/, 'cloud-runs does not thread resolved parent lineage'); - assert.ok(!fs.existsSync(path.join(ROOT, 'src/firefox/src/cloud-runs.js')), 'Firefox has no cloud-runs module — lineage threading is Chrome-only by platform boundary'); + const firefoxCloudRuns = fs.readFileSync(path.join(ROOT, 'src/firefox/src/cloud-runs.js'), 'utf8'); + assert.match(firefoxCloudRuns, /const parentTraceRunId = parentRun\?\.traceRunId \|\| null;/, 'Firefox cloud-runs does not use the completed parent trace'); + assert.match(firefoxCloudRuns, /parentRunId: parentTraceRunId,[\s\S]*?parentSessionId: parentTraceSessionId,/, 'Firefox cloud-runs does not thread resolved parent lineage'); +}); + +test('firefox: revoking the global API setting keeps a temporary cloud-run grant', () => { + const agent = new AgentFx({ getActive: () => ({ model: 'test-model' }) }); + const tabId = 17855; + agent.conversations.set(tabId, []); + agent.setTemporaryApiMutationsAllowed(tabId, true); + agent.apiAllowedInjected.add(tabId); + agent.setAlwaysAllowApiMutations(true); + agent.setAlwaysAllowApiMutations(false); + assert.equal(agent.isApiMutationsAllowed(tabId), true); + assert.equal(agent.conversations.get(tabId).length, 0, 'no NOT ALLOWED note while the temporary grant is active'); + assert.equal(agent.apiAllowedInjected.has(tabId), true); + agent.setTemporaryApiMutationsAllowed(tabId, false); + assert.equal(agent.conversations.get(tabId).length, 1, 'the note is appended once the last grant ends'); }); test('saved workflow replay captures its source lineage before claiming the tab', async () => {