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 @@
+
+
+
+
+
+
+
+
—
+
+
+
+
+
+
+
+
+
+
+
+