From b5fefab1b7eeef9a2218f1acdc6e208bac128246 Mon Sep 17 00:00:00 2001 From: Mitchou10 Date: Fri, 2 Oct 2026 11:20:25 +0200 Subject: [PATCH 1/4] Add Cloud Bridge browser registration and approval Enrich the cloud bridge hello with token, browserId, installationId and browser/extension/platform info. When a token is configured, each new socket must receive connection_approved before cloud_* commands run; connection_pending and connection_rejected are handled. Without a token the legacy local behaviour is unchanged. Add a Settings > Cloud Bridge tab (URL, token, browser name, test), a local example server, tests and docs (EN + FR guide). --- docs/cloud-bridge-browser-approval.md | 132 ++++++++++ docs/cloud-bridge-test-guide.fr.md | 229 ++++++++++++++++++ examples/cloud-bridge-approval-server.mjs | 131 ++++++++++ package.json | 3 +- src/chrome/src/background.js | 3 +- src/chrome/src/cloud-runs.js | 27 ++- src/chrome/src/offscreen/cloud-bridge.js | 74 +++++- src/chrome/src/ui/locales/ar.js | 2 + src/chrome/src/ui/locales/bn.js | 2 + .../src/ui/locales/cloud-bridge-copy.mjs | 19 ++ src/chrome/src/ui/locales/de.js | 2 + src/chrome/src/ui/locales/en.js | 2 + src/chrome/src/ui/locales/es.js | 2 + src/chrome/src/ui/locales/fa.js | 2 + src/chrome/src/ui/locales/fr.js | 2 + src/chrome/src/ui/locales/he.js | 2 + src/chrome/src/ui/locales/hi.js | 2 + src/chrome/src/ui/locales/id.js | 2 + src/chrome/src/ui/locales/ja.js | 2 + src/chrome/src/ui/locales/ko.js | 2 + src/chrome/src/ui/locales/ms.js | 2 + src/chrome/src/ui/locales/nl.js | 2 + src/chrome/src/ui/locales/pl.js | 2 + src/chrome/src/ui/locales/pt.js | 2 + src/chrome/src/ui/locales/ru.js | 2 + src/chrome/src/ui/locales/th.js | 2 + src/chrome/src/ui/locales/tl.js | 2 + src/chrome/src/ui/locales/tr.js | 2 + src/chrome/src/ui/locales/uk.js | 2 + src/chrome/src/ui/locales/vi.js | 2 + src/chrome/src/ui/locales/zh.js | 2 + src/chrome/src/ui/settings-cloud-bridge.js | 121 +++++++++ src/chrome/src/ui/settings.html | 39 +++ test/cloud-bridge-approval.mjs | 188 ++++++++++++++ 34 files changed, 1006 insertions(+), 6 deletions(-) create mode 100644 docs/cloud-bridge-browser-approval.md create mode 100644 docs/cloud-bridge-test-guide.fr.md create mode 100644 examples/cloud-bridge-approval-server.mjs create mode 100644 src/chrome/src/ui/locales/cloud-bridge-copy.mjs create mode 100644 src/chrome/src/ui/settings-cloud-bridge.js create mode 100644 test/cloud-bridge-approval.mjs diff --git a/docs/cloud-bridge-browser-approval.md b/docs/cloud-bridge-browser-approval.md new file mode 100644 index 0000000000..c73aff97a4 --- /dev/null +++ b/docs/cloud-bridge-browser-approval.md @@ -0,0 +1,132 @@ +# 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. + +- `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 0000000000..8eb2f7abae --- /dev/null +++ b/docs/cloud-bridge-test-guide.fr.md @@ -0,0 +1,229 @@ +# 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. + +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 # 9 tests : hello, pending, approved, rejected, commande avant/après, reconnexion, mode sans token, 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 0000000000..aca4983e06 --- /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 4473ac7735..be2b5597f4 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 95a7fe3ee2..19f0d9ebfd 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 68b72707ce..5c9f15a651 100644 --- a/src/chrome/src/cloud-runs.js +++ b/src/chrome/src/cloud-runs.js @@ -1391,9 +1391,34 @@ 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. + async function bridgeIdentity() { + const stored = await api.storage.local.get([ + 'webbrainCloudBridgeToken', + 'webbrainCloudBridgeBrowserId', + 'webbrainCloudBridgeInstallationId', + ]); + let installationId = stored.webbrainCloudBridgeInstallationId; + if (!installationId) { + installationId = crypto.randomUUID(); + await api.storage.local.set({ webbrainCloudBridgeInstallationId: installationId }); + } + 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 45708756c4..d6c491b008 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,22 @@ 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'); + try { nextSocket.close(); } catch {} + } + return; + } + const id = msg.id || null; const action = msg.action || msg.command; const payload = msg.payload || msg; @@ -109,6 +157,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 +183,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 +209,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 bd174f183a..64a9cbdc06 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 638da186ef..7f82d980ec 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 0000000000..32efb74205 --- /dev/null +++ b/src/chrome/src/ui/locales/cloud-bridge-copy.mjs @@ -0,0 +1,19 @@ +// English fallback for the Cloud Bridge settings tab; shared to keep locale keys aligned. +export default { + "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 c8a9eb233d..69d9cca3ca 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 7dd7d1552c..b9671a21eb 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 401c31a780..ed6ad68f6c 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 42e4a10071..b8cd251c90 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 e97b32b846..51a178e0cb 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 6e4ccfb7c2..75c357e065 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 d4e2e4d253..24eedae5c1 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 f5eadb61e2..dd70622680 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 ee0e9c5cf6..52f8935e77 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 aaa30d4b6d..72e1619717 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 8359ae65e8..753ca906ad 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 25d0f06ae0..bfc48f3118 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 7a3a048dc5..9eda77a069 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 1c1faf5381..54979297b5 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 82910a1f80..d03c85d5e1 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 82bf6e3985..0e25d184aa 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 69b6e42ed0..6e4ffe9e2a 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 aee4d10a17..8003eb4107 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 763b53fb7b..aade091417 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 5aa09db746..0793e70dd8 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 7d9a1ba067..d80d9056c3 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 0000000000..c74e41d9ef --- /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.display.cloud_bridge.invalid_url')); + } + return url.href; +} + +function render(status = {}) { + if (!enabled.checked || status.enabled === false) return setStatus('disabled', t('st.display.cloud_bridge.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.display.cloud_bridge.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 bc72125109..136a71d35c 100644 --- a/src/chrome/src/ui/settings.html +++ b/src/chrome/src/ui/settings.html @@ -1429,6 +1429,7 @@

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

+
+
+
+
+
+
+
+ +
+ + +
+ +
—
+
+ +
+
+ + +
+
+
+
+ + diff --git a/test/cloud-bridge-approval.mjs b/test/cloud-bridge-approval.mjs new file mode 100644 index 0000000000..fb173e1431 --- /dev/null +++ b/test/cloud-bridge-approval.mjs @@ -0,0 +1,188 @@ +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 }); return timers.length; }, + clearTimeout: () => {}, + }); + 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('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(); + } +}); From b6688867ae310635031ff62a0c1153a5115c1b87 Mon Sep 17 00:00:00 2001 From: Mitchou10 Date: Fri, 2 Oct 2026 11:37:25 +0200 Subject: [PATCH 2/4] Port Cloud Bridge and browser approval to Firefox Firefox has no offscreen document, so the bridge runs in the background page (src/firefox/src/cloud-bridge.js) with the same protocol as Chrome. Add the cloud-runs controller, cloud_* background actions, temporary API mutation support in the Firefox agent, the Cloud Bridge settings tab and shared English copy for all locales. Update the lineage test that asserted Firefox had no cloud-runs module, add Firefox bridge tests, and document Firefox setup. --- docs/cloud-bridge-browser-approval.md | 2 + docs/cloud-bridge-test-guide.fr.md | 8 + .../src/ui/locales/cloud-bridge-copy.mjs | 3 + src/chrome/src/ui/settings-cloud-bridge.js | 6 +- src/chrome/src/ui/settings.html | 4 +- src/firefox/src/agent/agent.js | 30 +- src/firefox/src/background.js | 59 +- src/firefox/src/cloud-bridge.js | 232 +++ src/firefox/src/cloud-runs.js | 1453 +++++++++++++++++ src/firefox/src/ui/locales/ar.js | 2 + src/firefox/src/ui/locales/bn.js | 2 + .../src/ui/locales/cloud-bridge-copy.mjs | 22 + src/firefox/src/ui/locales/de.js | 2 + src/firefox/src/ui/locales/en.js | 2 + src/firefox/src/ui/locales/es.js | 2 + src/firefox/src/ui/locales/fa.js | 2 + src/firefox/src/ui/locales/fr.js | 2 + src/firefox/src/ui/locales/he.js | 2 + src/firefox/src/ui/locales/hi.js | 2 + src/firefox/src/ui/locales/id.js | 2 + src/firefox/src/ui/locales/ja.js | 2 + src/firefox/src/ui/locales/ko.js | 2 + src/firefox/src/ui/locales/ms.js | 2 + src/firefox/src/ui/locales/nl.js | 2 + src/firefox/src/ui/locales/pl.js | 2 + src/firefox/src/ui/locales/pt.js | 2 + src/firefox/src/ui/locales/ru.js | 2 + src/firefox/src/ui/locales/th.js | 2 + src/firefox/src/ui/locales/tl.js | 2 + src/firefox/src/ui/locales/tr.js | 2 + src/firefox/src/ui/locales/uk.js | 2 + src/firefox/src/ui/locales/vi.js | 2 + src/firefox/src/ui/locales/zh.js | 2 + src/firefox/src/ui/settings-cloud-bridge.js | 117 ++ src/firefox/src/ui/settings.html | 90 + test/cloud-bridge-approval.mjs | 137 ++ test/run.js | 4 +- 37 files changed, 2205 insertions(+), 8 deletions(-) create mode 100644 src/firefox/src/cloud-bridge.js create mode 100644 src/firefox/src/cloud-runs.js create mode 100644 src/firefox/src/ui/locales/cloud-bridge-copy.mjs create mode 100644 src/firefox/src/ui/settings-cloud-bridge.js diff --git a/docs/cloud-bridge-browser-approval.md b/docs/cloud-bridge-browser-approval.md index c73aff97a4..e71c8f32e0 100644 --- a/docs/cloud-bridge-browser-approval.md +++ b/docs/cloud-bridge-browser-approval.md @@ -31,6 +31,8 @@ flowchart LR 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. diff --git a/docs/cloud-bridge-test-guide.fr.md b/docs/cloud-bridge-test-guide.fr.md index 8eb2f7abae..9e4a60b0a5 100644 --- a/docs/cloud-bridge-test-guide.fr.md +++ b/docs/cloud-bridge-test-guide.fr.md @@ -81,6 +81,14 @@ L'extension Chrome est directement chargeable depuis `src/chrome` (pas de build > 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 diff --git a/src/chrome/src/ui/locales/cloud-bridge-copy.mjs b/src/chrome/src/ui/locales/cloud-bridge-copy.mjs index 32efb74205..4df7ff44da 100644 --- a/src/chrome/src/ui/locales/cloud-bridge-copy.mjs +++ b/src/chrome/src/ui/locales/cloud-bridge-copy.mjs @@ -1,5 +1,8 @@ // 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.', diff --git a/src/chrome/src/ui/settings-cloud-bridge.js b/src/chrome/src/ui/settings-cloud-bridge.js index c74e41d9ef..c1ea169188 100644 --- a/src/chrome/src/ui/settings-cloud-bridge.js +++ b/src/chrome/src/ui/settings-cloud-bridge.js @@ -39,13 +39,13 @@ function setStatus(state, 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.display.cloud_bridge.invalid_url')); + 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.display.cloud_bridge.status_disabled')); + 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) { @@ -53,7 +53,7 @@ function render(status = {}) { 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.display.cloud_bridge.status_error', { error: status.lastError })); + 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 })); } diff --git a/src/chrome/src/ui/settings.html b/src/chrome/src/ui/settings.html index 136a71d35c..c4eefcf9f2 100644 --- a/src/chrome/src/ui/settings.html +++ b/src/chrome/src/ui/settings.html @@ -1720,7 +1720,7 @@

- +
@@ -2529,7 +2529,7 @@

- +
diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 3c07b7d77b..9137cd285d 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; @@ -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 48f1901ece..a128d478bd 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 0000000000..99f0282563 --- /dev/null +++ b/src/firefox/src/cloud-bridge.js @@ -0,0 +1,232 @@ +/** + * 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'); + 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 0000000000..1e00483fe7 --- /dev/null +++ b/src/firefox/src/cloud-runs.js @@ -0,0 +1,1453 @@ +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. + async function bridgeIdentity() { + const stored = await api.storage.local.get([ + 'webbrainCloudBridgeToken', + 'webbrainCloudBridgeBrowserId', + 'webbrainCloudBridgeInstallationId', + ]); + let installationId = stored.webbrainCloudBridgeInstallationId; + if (!installationId) { + installationId = crypto.randomUUID(); + await api.storage.local.set({ webbrainCloudBridgeInstallationId: installationId }); + } + 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 4d21438da7..9ffd963842 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 4474e21bf9..cdc4906f54 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 0000000000..4df7ff44da --- /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 09d18072c9..6891babaa3 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 5769c6be69..62e5d6f214 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 80b1e0c5b6..777825e4ce 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 d7022c6a5f..c2f05f43c3 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 b8a0df62d8..ae3b9f7bd2 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 98434aef9b..4d8358a362 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 91e12d9fee..bafcaf317b 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 e27bad9e59..1f4249354d 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 18dde6bda7..bc3d0fe106 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 3a1ad07b29..c17bcaa683 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 951012b134..5062754b35 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 506d1e93ff..17cc098841 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 472d4d8f2e..4a74e171d5 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 5a31427f6a..a239fbdd0d 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 4a5a6d8e0a..f146725995 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 4309916e59..f747b25f12 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 4f38631d8b..d120bd0f83 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 83b6922fd1..fc32d5029f 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 4b5492f9f2..130f03bc46 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 be0c47b3f4..f296405d0b 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 16ed61f326..cddf7eb1c4 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 0000000000..ef96246ef4 --- /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 5dc4ed834f..7dbef702ba 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 index fb173e1431..7a3b04ee57 100644 --- a/test/cloud-bridge-approval.mjs +++ b/test/cloud-bridge-approval.mjs @@ -186,3 +186,140 @@ test('end to end against the example server', async () => { 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: 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(); + } +}); diff --git a/test/run.js b/test/run.js index 76e8dd734e..54fb519689 100644 --- a/test/run.js +++ b/test/run.js @@ -26047,7 +26047,9 @@ 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('saved workflow replay captures its source lineage before claiming the tab', async () => { From 3f56969c8007df878b527442c1555777e7eee0e5 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Fri, 2 Oct 2026 13:38:55 +0300 Subject: [PATCH 3/4] Fix reconnect timer handling and update test guide Updated cloud-bridge.js files for Chrome and Firefox to clear reconnect timer on rejection. Updated test guide to reflect additional tests. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/cloud-bridge-test-guide.fr.md | 2 +- src/chrome/src/offscreen/cloud-bridge.js | 2 ++ src/firefox/src/cloud-bridge.js | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/cloud-bridge-test-guide.fr.md b/docs/cloud-bridge-test-guide.fr.md index 9e4a60b0a5..c575844723 100644 --- a/docs/cloud-bridge-test-guide.fr.md +++ b/docs/cloud-bridge-test-guide.fr.md @@ -204,7 +204,7 @@ Problèmes fréquents : ## 7. Tests automatisés ```bash -npm run test:cloud-bridge-approval # 9 tests : hello, pending, approved, rejected, commande avant/après, reconnexion, mode sans token, e2e vs serveur d'exemple +npm run test:cloud-bridge-approval # 14 tests : identité hello, pending, approved, rejected, commande avant/après, reconnexion, mode sans token, e2e vs serveur d'exemple (Chrome et Firefox) node test/run.js # suite complète existante (≈ 2400 tests) ``` diff --git a/src/chrome/src/offscreen/cloud-bridge.js b/src/chrome/src/offscreen/cloud-bridge.js index d6c491b008..165086f331 100644 --- a/src/chrome/src/offscreen/cloud-bridge.js +++ b/src/chrome/src/offscreen/cloud-bridge.js @@ -140,6 +140,8 @@ } else { approval = 'rejected'; lastError = String(msg.reason || 'Connection rejected by backend'); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; try { nextSocket.close(); } catch {} } return; diff --git a/src/firefox/src/cloud-bridge.js b/src/firefox/src/cloud-bridge.js index 99f0282563..8e17dec6fb 100644 --- a/src/firefox/src/cloud-bridge.js +++ b/src/firefox/src/cloud-bridge.js @@ -129,6 +129,8 @@ export function createCloudBridge({ dispatch, WebSocketImpl = globalThis.WebSock } else { approval = 'rejected'; lastError = String(msg.reason || 'Connection rejected by backend'); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; try { nextSocket.close(); } catch {} } return; From fa500a8d520b5f28dffe7fd45e5a51c04c157f88 Mon Sep 17 00:00:00 2001 From: Mitchou10 Date: Fri, 2 Oct 2026 12:57:35 +0200 Subject: [PATCH 4/4] Address review: single-flight installation id, cancel backoff on rejection - Serialize the read-or-create of the installation id in both cloud-runs controllers so concurrent bridge starts share one identity. - Cancel any pending reconnect timer when the backend rejects a socket (Chrome offscreen bridge and Firefox bridge). - Firefox setAlwaysAllowApiMutations now uses isApiMutationsAllowed so a temporary cloud-run grant is not revoked by a contradictory note. - Add regression tests for each and fix the stale test count in the guide. --- docs/cloud-bridge-test-guide.fr.md | 2 +- src/chrome/src/cloud-runs.js | 25 ++++++++--- src/chrome/src/offscreen/cloud-bridge.js | 1 + src/firefox/src/agent/agent.js | 2 +- src/firefox/src/cloud-bridge.js | 1 + src/firefox/src/cloud-runs.js | 25 ++++++++--- test/cloud-bridge-approval.mjs | 55 +++++++++++++++++++++++- test/run.js | 15 +++++++ 8 files changed, 110 insertions(+), 16 deletions(-) diff --git a/docs/cloud-bridge-test-guide.fr.md b/docs/cloud-bridge-test-guide.fr.md index c575844723..e6419f7674 100644 --- a/docs/cloud-bridge-test-guide.fr.md +++ b/docs/cloud-bridge-test-guide.fr.md @@ -204,7 +204,7 @@ Problèmes fréquents : ## 7. Tests automatisés ```bash -npm run test:cloud-bridge-approval # 14 tests : identité hello, pending, approved, rejected, commande avant/après, reconnexion, mode sans token, e2e vs serveur d'exemple (Chrome et Firefox) +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) ``` diff --git a/src/chrome/src/cloud-runs.js b/src/chrome/src/cloud-runs.js index 5c9f15a651..6be36b3bc5 100644 --- a/src/chrome/src/cloud-runs.js +++ b/src/chrome/src/cloud-runs.js @@ -1393,17 +1393,30 @@ export function createCloudRunController({ // 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', - 'webbrainCloudBridgeInstallationId', ]); - let installationId = stored.webbrainCloudBridgeInstallationId; - if (!installationId) { - installationId = crypto.randomUUID(); - await api.storage.local.set({ webbrainCloudBridgeInstallationId: installationId }); - } return { token: stored.webbrainCloudBridgeToken || '', browserId: stored.webbrainCloudBridgeBrowserId || installationId, diff --git a/src/chrome/src/offscreen/cloud-bridge.js b/src/chrome/src/offscreen/cloud-bridge.js index 165086f331..19d9fd4887 100644 --- a/src/chrome/src/offscreen/cloud-bridge.js +++ b/src/chrome/src/offscreen/cloud-bridge.js @@ -140,6 +140,7 @@ } 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 {} diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 9137cd285d..668b911b0a 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -7259,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( diff --git a/src/firefox/src/cloud-bridge.js b/src/firefox/src/cloud-bridge.js index 8e17dec6fb..01d0ba3a7b 100644 --- a/src/firefox/src/cloud-bridge.js +++ b/src/firefox/src/cloud-bridge.js @@ -129,6 +129,7 @@ export function createCloudBridge({ dispatch, WebSocketImpl = globalThis.WebSock } 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 {} diff --git a/src/firefox/src/cloud-runs.js b/src/firefox/src/cloud-runs.js index 1e00483fe7..f4a08e83f0 100644 --- a/src/firefox/src/cloud-runs.js +++ b/src/firefox/src/cloud-runs.js @@ -1395,17 +1395,30 @@ export function createCloudRunController({ // 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', - 'webbrainCloudBridgeInstallationId', ]); - let installationId = stored.webbrainCloudBridgeInstallationId; - if (!installationId) { - installationId = crypto.randomUUID(); - await api.storage.local.set({ webbrainCloudBridgeInstallationId: installationId }); - } return { token: stored.webbrainCloudBridgeToken || '', browserId: stored.webbrainCloudBridgeBrowserId || installationId, diff --git a/test/cloud-bridge-approval.mjs b/test/cloud-bridge-approval.mjs index 7a3b04ee57..d1a8394fbd 100644 --- a/test/cloud-bridge-approval.mjs +++ b/test/cloud-bridge-approval.mjs @@ -37,8 +37,8 @@ function harness({ WebSocketImpl, sendMessage = async () => ({ runId: 'r1', stat onMessage: { addListener: cb => { listener = cb; } }, sendMessage: async m => { runtimeCalls.push(m); return sendMessage(m); }, } }, - setTimeout: (callback, delay) => { timers.push({ callback, delay }); return timers.length; }, - clearTimeout: () => {}, + 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; @@ -153,6 +153,18 @@ test('reconnect requires a fresh approval', async () => { 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({}); @@ -264,6 +276,19 @@ test('firefox: reconnect requires a fresh approval', async () => { 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' }); @@ -323,3 +348,29 @@ test('firefox: end to end against the example server', async () => { 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 54fb519689..98dd6fdae0 100644 --- a/test/run.js +++ b/test/run.js @@ -26052,6 +26052,21 @@ test('trace lineage: _startTraceRun and replay plumb parent ids in both builds', 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 () => { for (const [browser, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { const tabId = browser === 'chrome' ? 17853 : 17854;